jobdone-shared-files 1.1.11 → 1.1.13

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.
@@ -0,0 +1,402 @@
1
+ <template>
2
+ <div id="ModalFileRepository" class="modal fade" :class="subModal ? 'sub-modal' : ''" ref="modalDom"
3
+ data-bs-backdrop="static" tabindex="-1" role="dialog" aria-hidden="true">
4
+ <div class="modal-dialog modal-dialog-scrollable" :class="classManager.modalSize" role="document">
5
+ <div class="modal-content">
6
+ <div class="modal-header">
7
+ <h5 class="modal-title"><span class="material-icons icon-18 me-2 text-primary">{{
8
+ optionsAdjust.modalIcon }}</span>{{ optionsAdjust.modalTitle }}</h5>
9
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
10
+ </div>
11
+ <div class="top-header">
12
+ <p>暫存清單</p>
13
+ <template v-if="selectedFilesSet.size > 0">
14
+ <div class="d-flex align-items-center gap-2">
15
+ <small><span>已選擇</span><span class="text-primary mx-1">{{ selectedFilesSet.size
16
+ }}</span><span>個檔案</span></small>
17
+ <button class="btn btn-link btn-sm" @click='clearSelected'><span
18
+ class="material-icons me-1">close</span>取消選取</button>
19
+ </div>
20
+ </template>
21
+ <template v-else>
22
+ <div class="form-check" v-if="optionsAdjust.onlyMyFileCanSwitch">
23
+ <input class="form-check-input" type="checkbox" :true-value="true" :false-value="false"
24
+ :id="`ModalFileRepository_MyFilesOnly_${componentUuid}`" v-model="onlyMyFile"
25
+ @change="reloadRepositoryFiles()" />
26
+ <label class="form-check-label is-unselectable"
27
+ :for="`ModalFileRepository_MyFilesOnly_${componentUuid}`">僅顯示我的檔案</label>
28
+ </div>
29
+ </template>
30
+ </div>
31
+ <div class="modal-body overflow-auto p-0" ref="previewWindow" style="position: relative;">
32
+ <div class="the-month-content m-3" v-for="theMonth in fileListPreview" :key="theMonth.monthKey">
33
+ <p class="the-month-content-header">{{ theMonth.monthLabel }}</p>
34
+ <div class="the-month-content-body">
35
+ <template v-for="(day, dayIdx) in theMonth.days"
36
+ :key="`${theMonth.monthKey}-${day.dayKey}`">
37
+ <div class="the-date-content" v-for="(item, itemIdx) in day.items"
38
+ :key="`${theMonth.monthKey}-${day.dayKey}-${itemIdx}`">
39
+ <p class="the-date-content-header" v-if="itemIdx === 0">{{ day.dayLabel }}</p>
40
+ <div class="the-date-content-body">
41
+ <div class="multimedia-select-content">
42
+ <button class="select-control"
43
+ @click.stop="selectMultimedia(item[optionsAdjust.bindingKey])"
44
+ :class="{ 'is-selected': selectedFilesSet.has(item[optionsAdjust.bindingKey]) }"></button>
45
+ </div>
46
+ <button class="multimedia-preview-content"
47
+ @click="previewImg(item[optionsAdjust.fileUrlKey], item.fileName)">
48
+ <div v-if="item.multimediaList?.length > 0"
49
+ class="the-date-content-multiple-tag">
50
+ </div>
51
+ <img :src="item[optionsAdjust.thumbnailUrlKey] || item[optionsAdjust.fileUrlKey]"
52
+ lazy="loaded" :alt="item.fileName" :title="item.fileName">
53
+ </button>
54
+ </div>
55
+ </div>
56
+ </template>
57
+ </div>
58
+ </div>
59
+ <div v-if="fileListPreview.length === 0" class="border-bottom text-center p-4 bg-gray-500">
60
+ <p class="mt-3">暫存媒體庫中沒有任何檔案</p>
61
+ </div>
62
+ <div ref="getNextPagesTarget" style="height: 1rem"></div>
63
+ </div>
64
+ <div class="modal-footer">
65
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
66
+ <button type="button" class="btn btn-primary" @click="submitSelected">送出選擇項目 ( {{
67
+ selectedFilesSet.size }}
68
+ )</button>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <vue-easy-lightbox ref="ezLb" :visible='previewImgAry.length > 0' :imgs="previewImgAry" :index="previewImgIdx"
74
+ @hide="onHide"></vue-easy-lightbox>
75
+ <Loading :is-active="isLoading"></Loading>
76
+ </template>
77
+ <script setup>
78
+ import axios from 'axios';
79
+ import { computed, ref, nextTick, toRaw, onMounted } from "vue";
80
+ import { modalShow, modalHide } from "./common/ModalSetup.js";
81
+ import Loading from "./vueLoadingOverlay.vue";
82
+ import VueEasyLightbox from 'vue-easy-lightbox';
83
+
84
+ const props = defineProps({
85
+ subModal: {
86
+ // 是否為子視窗(預設 false),子視窗會取消 backdrop-filter 效果,且高度略低於一般Modal
87
+ type: Boolean,
88
+ default: false
89
+ },
90
+ projectId: {
91
+ // 專案ID
92
+ type: String,
93
+ required: true
94
+ },
95
+ userId: {
96
+ // 使用者ID
97
+ type: String,
98
+ required: true
99
+ },
100
+ apiUrl: {
101
+ // 取得檔案列表的 API URL
102
+ type: String,
103
+ required: true
104
+ },
105
+ options: {
106
+ // 額外參數設定
107
+ // {
108
+ // modalSize: 'lg' // Modal的尺寸。可選值:sm, '', lg, xl。預設 'lg',
109
+ // modalTitle: '暫存媒體選擇器' // Modal的標題。預設 '暫存媒體選擇器'
110
+ // modalIcon: 'inventory_2', // Modal的圖示。預設 'inventory_2'
111
+ // onlyMyFileCanSwitch: true, // 是否顯示「僅顯示我的檔案」切換選項。預設 true
112
+ // onlyMyFileDefaultValue: true, // 「僅顯示我的檔案」預設選項。預設 true
113
+ // bindingKey: 'uid', // 選項唯一值的keyName。預設 'uid'
114
+ // createAtKey: 'createAt' // 檔案建立時間的keyName。預設 'createAt'
115
+ // fileUrlKey: 'fileUrl', // 檔案URL的keyName。預設'fileUrl'
116
+ // thumbnailUrlKey: 'thumbnailUrl', // 檔案縮圖URL的keyName。預設'thumbnailUrl'
117
+ // }
118
+ type: Object,
119
+ default: () => ({})
120
+ }
121
+ })
122
+ const emit = defineEmits([
123
+ 'submitSelected'
124
+ ])
125
+
126
+ const componentUuid = crypto.randomUUID()
127
+ const modalDom = ref(null)
128
+ const isLoading = ref(false)
129
+
130
+ // 參數設定
131
+ const optionConfigs = {
132
+ bindingKey: {
133
+ validate: (value) => typeof value === 'string' && value.length > 0,
134
+ defaultValue: 'uid'
135
+ },
136
+ createAtKey: {
137
+ validate: (value) => typeof value === 'string' && value.length > 0,
138
+ defaultValue: 'createAt'
139
+ },
140
+ fileUrlKey: {
141
+ validate: (value) => typeof value === 'string' && value.length > 0,
142
+ defaultValue: 'fileUrl'
143
+ },
144
+ thumbnailUrlKey: {
145
+ validate: (value) => typeof value === 'string' && value.length > 0,
146
+ defaultValue: 'thumbnailUrl'
147
+ },
148
+ modalSize: {
149
+ validate: (value) => typeof value === 'string' && ['sm', 'md', 'lg', 'xl'].includes(value),
150
+ defaultValue: 'lg' // 可選值:sm, '', lg, xl
151
+ },
152
+ modalTitle: {
153
+ validate: (value) => typeof value === 'string' && value.length > 0,
154
+ defaultValue: '暫存媒體選擇器'
155
+ },
156
+ modalIcon: {
157
+ validate: (value) => typeof value === 'string' && value.length > 0,
158
+ defaultValue: 'inventory_2'
159
+ },
160
+ onlyMyFileCanSwitch: {
161
+ validate: (value) => typeof value === 'boolean',
162
+ defaultValue: true // 是否顯示「僅顯示我的檔案」切換選項
163
+ },
164
+ onlyMyFileDefaultValue: {
165
+ validate: (value) => typeof value === 'boolean',
166
+ defaultValue: true // 「僅顯示我的檔案」預設選項。預設 true
167
+ }
168
+ }
169
+ const optionDefaults = Object.fromEntries(
170
+ Object.entries(optionConfigs).map(([key, config]) => [key, config.defaultValue])
171
+ )
172
+ const optionsAdjust = computed(() => {
173
+ const merged = { ...optionDefaults }
174
+ const incomingOptions = props.options || {}
175
+ for (const [key, value] of Object.entries(incomingOptions)) {
176
+ const config = optionConfigs[key]
177
+ if (!config) {
178
+ console.warn(`ModalFileRepositorySelector: 未知參數 ${key}`)
179
+ continue
180
+ }
181
+ if (!config.validate(value)) {
182
+ console.warn(`ModalFileRepositorySelector: 參數 ${key} 的型態錯誤,將使用預設值 ${config.defaultValue}`)
183
+ continue
184
+ }
185
+ merged[key] = value
186
+ }
187
+ return merged
188
+ })
189
+ const classManager = computed(() => {
190
+ return {
191
+ modalSize: optionsAdjust.value.modalSize ? `modal-${optionsAdjust.value.modalSize}` : `modal-${optionDefaults.modalSize}`
192
+ }
193
+ })
194
+
195
+ const ezLb = ref(null)
196
+ const previewImgAry = ref([])
197
+ const previewImgIdx = ref(0)
198
+ const previewImg = (url, title = '') => { previewImgAry.value = [{ src: url, title: title }] }
199
+ const onHide = () => { previewImgIdx.value = 0; previewImgAry.value = [] }
200
+
201
+ const debounce = (func, delay = 200) => {
202
+ let timeout;
203
+
204
+ return function (...args) {
205
+ const context = this;
206
+ clearTimeout(timeout); // 清除上一次的計時器
207
+ timeout = setTimeout(() => func.apply(context, args), delay);
208
+ };
209
+ }
210
+
211
+
212
+ // 滾動讀取
213
+ let observer = null
214
+ const previewWindow = ref(null)
215
+ const getNextPagesTarget = ref(null)
216
+ const checkNeedLoadMore = () => { // 檢查是否需要繼續載入(如果內容高度不足以觸發滾動)
217
+ if (getNextPagesTarget.value && getNextPagesTarget.value.getBoundingClientRect().top <= window.innerHeight) {
218
+ getRepositoryFiles()
219
+ }
220
+ }
221
+
222
+ // 開啟光箱
223
+ const show = async (selectedFilesUid) => {
224
+ modalShow(modalDom.value, afterCloseModal)
225
+ if (selectedFilesUid && selectedFilesUid.length > 0) {
226
+ selectedFilesSet.value = new Set(selectedFilesUid)
227
+ }
228
+ if (!observer) {
229
+ observer = new IntersectionObserver((entries) => {
230
+ entries.forEach(entry => {
231
+ if (entry.isIntersecting && (getNextPagesTarget.value && getNextPagesTarget.value.getBoundingClientRect().top <= window.innerHeight)) {
232
+ getRepositoryFiles()
233
+ }
234
+ });
235
+ }, {
236
+ root: previewWindow.value,
237
+ });
238
+ }
239
+ observer.observe(getNextPagesTarget.value);
240
+ }
241
+
242
+ // 關閉光箱時的處理
243
+ const afterCloseModal = () => {
244
+ observer.disconnect()
245
+ selectedFilesSet.value.clear()
246
+ }
247
+
248
+ // 載入檔案
249
+ const repositoryFiles = ref([])
250
+ const fileListPreview = computed(() => { // 預覽用陣列
251
+ const flatMap = repositoryFiles.value.flatMap(pageGroups => pageGroups.data.map(item => ({ ...item, page: pageGroups.page })))
252
+
253
+ // 先按日期排序(最新的在前)
254
+ const sortedItems = flatMap.sort((a, b) => new Date(b[optionsAdjust.value.createAtKey]) - new Date(a[optionsAdjust.value.createAtKey]))
255
+
256
+ // 分組處理
257
+ const groupedData = []
258
+ const monthGroupMap = new Map() // 用 Map 來快速查找月份群組
259
+ const dayGroupMap = new Map() // 用 Map 來快速查找日期群組
260
+
261
+ sortedItems.forEach(item => {
262
+ const date = new Date(item[[optionsAdjust.value.createAtKey]])
263
+ const year = date.getFullYear()
264
+ const month = date.getMonth() + 1
265
+ const day = date.getDate()
266
+
267
+ const monthKey = `${year}-${month.toString().padStart(2, '0')}`
268
+ const dayKey = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
269
+
270
+ // 檢查是否需要新增月份群組
271
+ let currentMonthGroup = monthGroupMap.get(monthKey)
272
+ if (!currentMonthGroup) {
273
+ currentMonthGroup = {
274
+ monthKey,
275
+ monthLabel: `${year}年${month}月`,
276
+ days: []
277
+ }
278
+ monthGroupMap.set(monthKey, currentMonthGroup)
279
+ groupedData.push(currentMonthGroup)
280
+ }
281
+
282
+ // 檢查是否需要新增日期群組
283
+ let currentDayGroup = dayGroupMap.get(dayKey)
284
+ if (!currentDayGroup) {
285
+ currentDayGroup = {
286
+ dayKey,
287
+ dayLabel: `${month}月${day}日`,
288
+ items: []
289
+ }
290
+ dayGroupMap.set(dayKey, currentDayGroup)
291
+ currentMonthGroup.days.push(currentDayGroup)
292
+ }
293
+ // 加入項目到日期群組
294
+ currentDayGroup.items.push(item)
295
+ })
296
+ return groupedData
297
+ })
298
+ const onlyMyFile = ref(true)
299
+ const repositoryFilesPagination = ref({
300
+ pagesToLoad: new Set([1]), // 應載入的頁數
301
+ totalPages: undefined,
302
+ size: 20
303
+ })
304
+ const getRepositoryFiles = async () => {
305
+ isLoading.value = true
306
+ try {
307
+ const fileListPages = new Set(repositoryFiles.value.map(pg => pg.page)) // 已載入頁數
308
+ let needLoadPages = Array.from(repositoryFilesPagination.value.pagesToLoad).filter(pg => !fileListPages.has(pg)) // 需要載入的頁數
309
+ if (!(repositoryFilesPagination.value.totalPages === undefined)) {
310
+ needLoadPages = needLoadPages.filter(pg => pg <= repositoryFilesPagination.value.totalPages) // 過濾超出總頁數的
311
+ }
312
+ if (needLoadPages.length < 1) return // 無需載入,跳出迴圈
313
+
314
+ const getPromise = []
315
+ needLoadPages.forEach(page => {
316
+ getPromise.push(axios.get(props.apiUrl, {
317
+ params: {
318
+ userId: props.userId,
319
+ projectId: props.projectId,
320
+ onlyMyFile: onlyMyFile.value,
321
+ page: page,
322
+ limit: repositoryFilesPagination.value.size
323
+ }
324
+ }))
325
+ })
326
+
327
+ const pageResults = await Promise.allSettled(getPromise)
328
+ pageResults.forEach((result) => {
329
+ console.log(result)
330
+ if (result.status === 'rejected') {
331
+ throw new Error('載入檔案列表失敗')
332
+ }
333
+ if (!result.value?.headers?.["x-pagination"]) {
334
+ throw new Error('分頁資訊遺失')
335
+ }
336
+ if (result.status === 'fulfilled') {
337
+ const resPagination = JSON.parse(result.value.headers["x-pagination"])
338
+ repositoryFiles.value = repositoryFiles.value.filter(pg => pg.page !== resPagination.CurrentPage) // 移除重複頁數
339
+ repositoryFiles.value.push({ data: result.value.data, page: resPagination.CurrentPage });
340
+ repositoryFilesPagination.value.totalPages = resPagination.TotalPages;
341
+ }
342
+ });
343
+
344
+ const nextPage = repositoryFilesPagination.value.pagesToLoad.size > 0
345
+ ? Math.max(...repositoryFilesPagination.value.pagesToLoad) + 1
346
+ : 1
347
+ if (repositoryFilesPagination.value.totalPages && nextPage > repositoryFilesPagination.value.totalPages) return // 已達最後頁,跳出迴圈
348
+ repositoryFilesPagination.value.pagesToLoad.add(nextPage)
349
+ await nextTick()
350
+ checkNeedLoadMore()
351
+ } catch (error) {
352
+ console.log(error)
353
+ } finally {
354
+ isLoading.value = false
355
+ }
356
+ }
357
+ const reloadRepositoryFiles = debounce(async () => {
358
+ repositoryFilesPagination.value = {
359
+ pagesToLoad: new Set([1]),
360
+ totalPages: undefined,
361
+ size: 20
362
+ }
363
+ repositoryFiles.value = []
364
+ await nextTick()
365
+ await getRepositoryFiles()
366
+ }, 200)
367
+
368
+
369
+ // 選取檔案
370
+ const selectedFilesSet = ref(new Set())
371
+ const selectMultimedia = (multimediaUid) => {
372
+ if (multimediaUid === undefined || multimediaUid === null) return
373
+ if (selectedFilesSet.value.has(multimediaUid)) {
374
+ selectedFilesSet.value.delete(multimediaUid)
375
+ return
376
+ }
377
+ selectedFilesSet.value.add(multimediaUid)
378
+ }
379
+ const clearSelected = () => {
380
+ selectedFilesSet.value.clear()
381
+ }
382
+
383
+ const submitSelected = () => {
384
+ let res = repositoryFiles.value.flatMap(pageGroups => pageGroups.data).filter(item => selectedFilesSet.value.has(item[optionsAdjust.value.bindingKey])) || []
385
+ res = res.reduce((acc, item) => {
386
+ acc.push(toRaw(item))
387
+ return acc
388
+ }, [])
389
+ // console.log(res)
390
+ emit('submitSelected', res)
391
+ modalHide(modalDom.value)
392
+ }
393
+ onMounted(() => {
394
+ onlyMyFile.value = optionsAdjust.value.onlyMyFileDefaultValue
395
+ })
396
+ defineExpose({
397
+ show,
398
+ onlyMyFile
399
+ })
400
+ </script>
401
+ <!-- styleCss請自行在 js 或 scss 引入
402
+ @import "../scss/Helpers/_ModalFileRepository.scss"; -->
@@ -0,0 +1,56 @@
1
+ import Modal from 'bootstrap/js/dist/modal.js';
2
+
3
+ /** 移除焦點Function */
4
+ const blurHandler = () => {
5
+ document.activeElement && document.activeElement.blur();
6
+ };
7
+
8
+ /** 取得與 DOM 元素關聯的Modal實例,或在未初始化的情況下建立新的Modal實例。
9
+ * @param { HTMLElement } modalDom 光箱的DOM
10
+ */
11
+ export const getOrCreateInstance = (modalDom) => {
12
+ return Modal.getOrCreateInstance(modalDom);
13
+ }
14
+
15
+ /**
16
+ * 開啟光箱
17
+ * @param { HTMLElement } modalDom 光箱的DOM
18
+ * @param { Function } modalHiddenFun 關閉光箱後的回調函數(等待 CSS 轉換完成後執行)
19
+ * @param { Boolean } hidePrevented static = true 時,使用者點選黑色遮罩被阻止關閉光箱時的回調函數
20
+ */
21
+ export const modalShow = (modalDom, modalHiddenFun, hidePreventedFun) => {
22
+ const removeEventListeners = () => {
23
+ modalDom.removeEventListener('hide.bs.modal', blurHandler);
24
+ modalDom.removeEventListener('hidden.bs.modal', afterCloseModalFun);
25
+ modalDom.removeEventListener('hidePrevented.bs.modal', hidePreventedFun);
26
+ }
27
+
28
+ const afterCloseModalFun = () => {
29
+ removeEventListeners();
30
+ if (modalHiddenFun && typeof modalHiddenFun === 'function') {
31
+ modalHiddenFun();
32
+ }
33
+ };
34
+
35
+ // 先移除事件監聽,避免重複添加
36
+ removeEventListeners();
37
+
38
+ modalDom.addEventListener('hide.bs.modal', blurHandler); // 關閉光箱時需移除焦點,避免aria-hidden="true"的元素仍然有焦點
39
+ modalDom.addEventListener('hidden.bs.modal', afterCloseModalFun);
40
+ if (hidePreventedFun && typeof hidePreventedFun === 'function') {
41
+ modalDom.addEventListener('hidePrevented.bs.modal', hidePreventedFun);
42
+ }
43
+
44
+ getOrCreateInstance(modalDom).show();
45
+ };
46
+
47
+ /**
48
+ * 手動關閉光箱
49
+ * @param { HTMLElement } modalDom 光箱的DOM
50
+ */
51
+ export const modalHide = (modalDom, dispose = false) => {
52
+ getOrCreateInstance(modalDom).hide();
53
+ if (dispose) {
54
+ getOrCreateInstance(modalDom).dispose();
55
+ }
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jobdone-shared-files",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "Shared JS and SCSS for Jobdone Enterprise.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,365 @@
1
+ #ModalFileRepository {
2
+ &.modal.sub-modal {
3
+ backdrop-filter: none;
4
+ background-color: rgba(#000000, 0.1);
5
+
6
+
7
+ .modal-content {
8
+ box-shadow: var(--bs-box-shadow-lg);
9
+ }
10
+
11
+ >.modal-dialog:not(.modal-fullscreen) {
12
+ max-height: calc(100% - 100px);
13
+ margin-top: 60px;
14
+ margin-bottom: 40px;
15
+
16
+ @each $key,
17
+ $breakpoint in $grid-breakpoints {
18
+ @media (max-width: $breakpoint) {
19
+ &.modal-fullscreen-#{$key}-down {
20
+ max-height: 100%;
21
+ margin-top: 0;
22
+ margin-bottom: 0;
23
+ }
24
+ }
25
+
26
+ }
27
+ }
28
+ }
29
+
30
+ .the-month-content {
31
+ margin-bottom: 2rem;
32
+
33
+
34
+ .the-month-content-header {
35
+ font-size: 1.25rem;
36
+ border-bottom: 1px solid var(--gray-300);
37
+ padding-top: .75rem;
38
+ padding-bottom: .5rem;
39
+ margin-bottom: .5rem;
40
+ position: sticky;
41
+ top: 0;
42
+ backdrop-filter: blur(2px);
43
+ background-color: rgba(white, .8);
44
+ z-index: 10;
45
+ }
46
+
47
+ .the-month-content-body {
48
+ display: grid;
49
+ gap: .25rem;
50
+ grid-template-columns: repeat(auto-fill, minmax(8.6rem, 1fr));
51
+ }
52
+
53
+ .the-date-content {
54
+ display: flex;
55
+ justify-content: flex-end;
56
+ flex-direction: column;
57
+ position: relative;
58
+
59
+ .the-date-content-header {
60
+ margin-bottom: 0.25rem;
61
+ font-size: 0.875rem;
62
+ display: grid;
63
+ grid-template-columns: 3px auto 1fr;
64
+ gap: 0.5rem;
65
+ line-height: 1.25rem;
66
+ color: var(--gray-800);
67
+ padding-top: 0.5rem;
68
+ padding-bottom: .5rem;
69
+ margin-bottom: auto;
70
+
71
+ &::before {
72
+ display: block;
73
+ content: " ";
74
+ width: 3px;
75
+ background-color: var(--bs-primary);
76
+ border-radius: 99px;
77
+ }
78
+ }
79
+
80
+ .the-date-content-body {
81
+ position: relative;
82
+ padding: 0;
83
+ transition: .1s ease-out;
84
+
85
+ &:has(.is-selected) {
86
+ border: 3px solid rgba(var(--bs-primary-rgb), 0.8);
87
+ border-radius: calc(var(--bs-border-radius) + 3px);
88
+ background-color: #fff;
89
+ transform: scale(.95);
90
+
91
+ .multimedia-preview-content {
92
+ transform: scale(.95);
93
+ }
94
+ }
95
+ }
96
+
97
+ .the-date-content-footer {
98
+ opacity: 0;
99
+ position: absolute;
100
+ transition: .3s ease-out;
101
+ bottom: 0;
102
+ z-index: 10;
103
+ padding: 0.5rem;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: flex-end;
107
+ width: 100%;
108
+ gap: .5rem;
109
+ background: linear-gradient(#00000000, #000000b0);
110
+ border-bottom-left-radius: var(--bs-border-radius);
111
+ border-bottom-right-radius: var(--bs-border-radius);
112
+
113
+ .save-file,
114
+ .more-info {
115
+ width: 1.5rem;
116
+ height: 1.5rem;
117
+ background-color: transparent;
118
+ z-index: 10;
119
+ margin: 0;
120
+ font-size: 0.5rem;
121
+ display: flex;
122
+ border: none;
123
+ align-items: center;
124
+ justify-content: center;
125
+ color: #fff;
126
+ transition: 0.3s ease-out;
127
+
128
+ &::before {
129
+ transition: 0.3s ease-out;
130
+ font-size: 1.25rem;
131
+ }
132
+
133
+ &:hover::before {
134
+ color: var(--blue-500);
135
+ }
136
+ }
137
+
138
+ .save-file {
139
+ &::before {
140
+ content: "file_download";
141
+ font-family: "Material Icons";
142
+ }
143
+
144
+ &:hover {
145
+ cursor: pointer;
146
+ }
147
+ }
148
+
149
+ .more-info {
150
+ &::before {
151
+ content: "info";
152
+ font-family: "Material Icons";
153
+ }
154
+
155
+ &:hover {
156
+ cursor: pointer;
157
+ }
158
+
159
+ }
160
+ }
161
+
162
+ &:hover,
163
+ &.batch-operating {
164
+
165
+ .the-date-content-footer,
166
+ .select-control {
167
+ opacity: 1;
168
+ }
169
+
170
+ .multimedia-preview-content.is-video::before {
171
+ transform: scale(1.1);
172
+ }
173
+ }
174
+
175
+ &:hover {
176
+ cursor: pointer;
177
+
178
+ img {
179
+ transform: scale(1.05);
180
+ }
181
+ }
182
+ }
183
+
184
+ .multimedia-select-content {
185
+ position: absolute;
186
+ width: 100%;
187
+ width: -webkit-fill-available;
188
+
189
+ .select-control {
190
+ border: 0;
191
+ background-color: transparent;
192
+ top: 0;
193
+ right: 0;
194
+ padding: 0.5rem;
195
+ z-index: 10;
196
+ margin: 0;
197
+ position: absolute;
198
+ user-select: none;
199
+ // opacity: 0;
200
+ transition: opacity 0.2s ease-out;
201
+
202
+ &::before {
203
+ content: "check";
204
+ display: block;
205
+ position: absolute;
206
+ margin: auto;
207
+ color: #fff;
208
+ font-size: 1rem;
209
+ line-height: 1rem;
210
+ opacity: .5;
211
+ font-family: "Material Icons";
212
+ z-index: 10;
213
+ width: 1rem;
214
+ height: 1rem;
215
+ left: calc(50% - 0.5rem);
216
+ top: calc(50% - 0.5rem);
217
+ transform: translateY(-1px);
218
+ transition: opacity 0.2s ease-out;
219
+ }
220
+
221
+ &::after {
222
+ opacity: .8;
223
+ border: 2px solid;
224
+ border-color: #ffffff85;
225
+ content: " ";
226
+ display: block;
227
+ width: 1.75rem;
228
+ height: 1.75rem;
229
+ background-color: rgba(#000, 0.2);
230
+ border-radius: var(--bs-border-radius);
231
+ backdrop-filter: blur(2px);
232
+ transition: 0.2s ease-out;
233
+ }
234
+
235
+ &:hover {
236
+ cursor: pointer;
237
+
238
+ &::before {
239
+ opacity: 1;
240
+ }
241
+
242
+ &::after {
243
+ // background-color: rgba(100, 122, 241, 0);
244
+ }
245
+ }
246
+
247
+ &.is-selected {
248
+ opacity: 1;
249
+
250
+ &::before {
251
+ opacity: 1;
252
+ }
253
+
254
+ &::after {
255
+ opacity: 1;
256
+ background-color: var(--bs-primary);
257
+ border-color: #fff;
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ .multimedia-preview-content {
264
+ position: relative;
265
+ display: block;
266
+ border: 0;
267
+ background-color: transparent;
268
+ width: 100%;
269
+ padding-top: 100%;
270
+ overflow: hidden;
271
+ background-color: var(--purple-100);
272
+ border-radius: var(--bs-border-radius);
273
+
274
+ img {
275
+ transition: 0.3s ease-out;
276
+ border-radius: var(--bs-border-radius);
277
+ width: 100%;
278
+ height: 100%;
279
+ object-fit: cover;
280
+ position: absolute;
281
+ top: 0;
282
+ left: 0;
283
+ /* 確保圖片覆蓋整個方形區域 */
284
+ }
285
+
286
+
287
+
288
+ &.is-video::before {
289
+ position: absolute;
290
+ transition: 0.3s ease-out;
291
+ background-color: rgba(#000, 0.4);
292
+ color: #fff;
293
+ backdrop-filter: blur(2px);
294
+ display: flex;
295
+ justify-content: center;
296
+ align-items: center;
297
+ width: 2.8rem;
298
+ height: 2.8rem;
299
+ top: calc(50% - 1.4rem);
300
+ left: calc(50% - 1.4rem);
301
+ box-shadow: 0 0px 12px 4px #c3c3c396;
302
+ z-index: 100;
303
+ font-family: "Material Icons";
304
+ content: "play_arrow";
305
+ font-size: 1.5rem;
306
+ border-radius: 99px;
307
+ }
308
+ }
309
+
310
+
311
+ }
312
+
313
+ .is-unselectable {
314
+ user-select: none;
315
+ }
316
+
317
+ .top-header {
318
+ display: flex;
319
+ align-items: center;
320
+ border-bottom: 1px solid var(--gray-300);
321
+ background-color: var(--gray-100);
322
+
323
+ >* {
324
+ padding: .5rem 1rem;
325
+ }
326
+
327
+ p {
328
+ color: var(--gray-500);
329
+ margin-bottom: 0;
330
+ margin-right: auto;
331
+ }
332
+ }
333
+
334
+ .the-date-content-multiple-tag {
335
+ position: absolute;
336
+ z-index: 2;
337
+ top: 0;
338
+ left: 0;
339
+ border: none;
340
+ background-color: transparent;
341
+ padding: 0;
342
+ margin: 0;
343
+
344
+
345
+
346
+ &::before {
347
+ height: 1.875rem;
348
+ width: 2rem;
349
+ content: "insert_link";
350
+ font-family: "Material Icons";
351
+ font-size: 1rem;
352
+ line-height: 1rem;
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: center;
356
+ background-color: rgba(var(--green-500-rgb), .9);
357
+ color: #fff;
358
+ border-top-left-radius: var(--bs-border-radius);
359
+ border-bottom-right-radius: var(--bs-border-radius);
360
+ top: 0;
361
+ left: 0;
362
+ transition: .1s ease-out;
363
+ }
364
+ }
365
+ }
package/tagEditor.vue CHANGED
@@ -13,135 +13,156 @@
13
13
  </div>
14
14
  </div>
15
15
  </template>
16
- <input v-if="!disabled" class="new-tag-input" list="datalistOptions" type="text" :placeholder="placeholder"
17
- v-model.trim="newTag" @keydown.enter="addTag($event)" @keyup="keyupFunction">
18
- <datalist id="datalistOptions" v-if="autoCompleteShow">
19
- <option :value="opt" v-for="(opt, idx) in autoCompleteOption" :key="idx">{{ opt }}</option>
20
- </datalist>
16
+ <template v-if="!disabled && !isMaxTagCountReached">
17
+ <input class="new-tag-input" list="datalistOptions" type="text" :placeholder="placeholder"
18
+ :maxlength="singleTagMaxLength || 50" v-model.trim="newTag" @keydown.enter="addTag($event)"
19
+ @keyup="keyupFunction">
20
+ <datalist id="datalistOptions" v-if="autoCompleteShow">
21
+ <option :value="opt" v-for="(opt, idx) in autoCompleteOption" :key="idx">{{ opt }}</option>
22
+ </datalist>
23
+ </template>
21
24
  </div>
22
25
  </div>
23
26
  <div class="helper-text-box" v-if="!disabled">
24
- <small class="helper-text">在輸入框中輸入文字後,按下鍵盤的"Enter"鍵,即可增加標籤。(標籤僅接受中文、英文、數字、下底線)</small>
27
+ <small class="helper-text">在輸入框中輸入文字後,按下鍵盤的「Enter鍵」即可增加標籤。標籤僅接受中文、英文、數字、下底線、<span class="text-danger">長度限制
28
+ {{ singleTagMaxLength }} 個字元</span>。
29
+ <template v-if="maxTagCount > 0">
30
+ <span class="text-danger">最多新增 {{ maxTagCount }} 個標籤</span>,
31
+ </template>標籤不可重複。</small>
25
32
  </div>
26
33
  </div>
27
34
  </template>
28
- <script>
35
+ <script setup>
29
36
  import { ref, reactive, onMounted, computed } from 'vue'
30
-
31
- export default {
32
- props: {
33
- defaultTagList: {
34
- type: Array,
35
- default: () => []
36
- },
37
- placeholder: {
38
- type: String,
39
- default: '請在此處輸入標籤文字'
40
- },
41
- autoComplete: {
42
- type: Boolean,
43
- default: true
44
- },
45
- autoCompleteOption: {
46
- type: Array,
47
- default: () => []
48
- },
49
- disabled: {
50
- type: Boolean,
51
- default: false
52
- }
37
+ const props = defineProps({
38
+ defaultTagList: {
39
+ type: Array,
40
+ default: () => []
53
41
  },
54
- setup(props, { emit }) {
55
- // 基本資料
56
- const tagAry = reactive([])
57
- const newTag = ref('')
58
-
59
- const autoCompleteShow = computed(() => {
60
- let show = props.autoComplete
61
- if (!newTag.value || props.autoCompleteOption.length < 1) {
62
- show = false
63
- }
64
- return show
65
- })
66
- // 輸入時 AutoComplete
67
- var autoCompleteTimer
68
- function updateAutoCompleteOption() {
69
- clearTimeout(autoCompleteTimer);
70
- if (newTag.value) {
71
- autoCompleteTimer = setTimeout(() => {
72
- emit('update-auto-complete-option', newTag.value);
73
- }, 500);
74
- }
75
- }
42
+ placeholder: {
43
+ type: String,
44
+ default: '請輸入標籤文字'
45
+ },
46
+ autoComplete: {
47
+ type: Boolean,
48
+ default: true
49
+ },
50
+ autoCompleteOption: {
51
+ type: Array,
52
+ default: () => []
53
+ },
54
+ disabled: {
55
+ type: Boolean,
56
+ default: false
57
+ },
58
+ singleTagMaxLength: {
59
+ type: Number,
60
+ default: 50
61
+ },
62
+ maxTagCount: {
63
+ type: Number,
64
+ default: 0
65
+ }
66
+ })
76
67
 
77
- // 刷新預設選中內容
78
- function getDefaultTag(updateData = []) {
79
- tagAry.splice(0, tagAry.length, ...updateData)
80
- }
81
- function keyupFunction() {
82
- if (props.autoCompleteOption) {
83
- updateAutoCompleteOption()
84
- }
85
- newTag.value = newTag.value.replace(/[^\a-\z\A-\Z0-9\u4E00-\u9FA5]/g, '_')
86
- }
68
+ // 基本資料
69
+ const tagAry = reactive([])
70
+ const newTag = ref('')
87
71
 
88
- // 重複標籤禁止新增
89
- var isDuplicate = computed(() => {
90
- return newTag.value ? !!tagAry.find(tag => tag == newTag.value) : false
91
- })
72
+ const autoCompleteShow = computed(() => {
73
+ let show = props.autoComplete
74
+ if (!newTag.value || props.autoCompleteOption.length < 1) {
75
+ show = false
76
+ }
77
+ return show
78
+ })
92
79
 
93
- // 增加Tag
94
- function addTag(event) {
95
- // 檢查是否為中文輸入法組合狀態
96
- if (event.isComposing || event.keyCode === 229) {
97
- return
98
- }
99
- if (newTag.value && !isDuplicate.value) {
100
- tagAry.push(newTag.value)
101
- }
102
- newTag.value = ''
103
- }
104
- // 移除Tag
105
- function removeTag(index) {
106
- tagAry.splice(index, 1)
107
- }
108
- function reset() {
109
- tagAry.splice(0, tagAry.length)
110
- newTag.value = ''
111
- }
80
+ // 輸入時 AutoComplete
81
+ var autoCompleteTimer
82
+ function updateAutoCompleteOption() {
83
+ clearTimeout(autoCompleteTimer);
84
+ if (newTag.value) {
85
+ autoCompleteTimer = setTimeout(() => {
86
+ emit('update-auto-complete-option', newTag.value);
87
+ }, 500);
88
+ }
89
+ }
112
90
 
113
- onMounted(() => {
114
- getDefaultTag(props.defaultTagList)
115
- })
116
- return {
117
- tagAry,
118
- newTag,
119
- addTag,
120
- removeTag,
121
- updateAutoCompleteOption,
122
- autoCompleteTimer,
123
- autoCompleteShow,
124
- reset,
125
- keyupFunction,
126
- isDuplicate,
127
- getDefaultTag
128
- }
91
+ // 刷新預設選中內容
92
+ function getDefaultTag(updateData = []) {
93
+ tagAry.splice(0, tagAry.length, ...updateData)
94
+ }
95
+ function keyupFunction() {
96
+ if (props.autoCompleteOption) {
97
+ updateAutoCompleteOption()
98
+ }
99
+ newTag.value = newTag.value.replace(/[^\a-\z\A-\Z0-9\u4E00-\u9FA5]/g, '_')
100
+ }
101
+
102
+ // 重複標籤禁止新增
103
+ var isDuplicate = computed(() => {
104
+ return newTag.value ? !!tagAry.find(tag => tag == newTag.value) : false
105
+ })
106
+ const isMaxTagCountReached = computed(() => {
107
+ return props.maxTagCount > 0 ? tagAry.length >= props.maxTagCount : false
108
+ })
109
+
110
+ // 增加Tag
111
+ function addTag(event) {
112
+ // 檢查是否為中文輸入法組合狀態
113
+ if (event.isComposing || event.keyCode === 229) {
114
+ return
115
+ }
116
+ if (newTag.value && !isDuplicate.value && !isMaxTagCountReached.value) {
117
+ tagAry.push(newTag.value)
129
118
  }
119
+ newTag.value = ''
130
120
  }
121
+ // 移除Tag
122
+ function removeTag(index) {
123
+ tagAry.splice(index, 1)
124
+ }
125
+ function reset() {
126
+ tagAry.splice(0, tagAry.length)
127
+ newTag.value = ''
128
+ }
129
+
130
+ onMounted(() => {
131
+ getDefaultTag(props.defaultTagList)
132
+ })
133
+
134
+ defineExpose({
135
+ tagAry,
136
+ newTag,
137
+ addTag,
138
+ removeTag,
139
+ updateAutoCompleteOption,
140
+ autoCompleteTimer,
141
+ autoCompleteShow,
142
+ reset,
143
+ keyupFunction,
144
+ isDuplicate,
145
+ getDefaultTag,
146
+ isMaxTagCountReached
147
+ })
148
+ const emit = defineEmits(['update-auto-complete-option'])
149
+
131
150
  </script>
132
151
 
133
152
  <style lang="scss" scoped>
134
153
  .tag-editor {
135
- background: var(--bs-gray-100);
136
- border-radius: var(--bs-border-radius);
137
- padding: .5rem;
154
+ background: var(--bs-gray-100, #f9f9fd);
155
+ border-radius: var(--bs-border-radius-lg, 0.5rem);
156
+ padding: .75rem;
138
157
 
139
158
  .helper-text-box {
159
+ margin-top: .25rem;
140
160
  text-align: start;
141
161
 
142
162
  .helper-text {
143
- font-size: 0.75rem;
144
- color: var(--bs-gray-500);
163
+ line-height: 125%;
164
+ font-size: 0.875rem;
165
+ color: var(--bs-gray-500, #A9B3BD);
145
166
  }
146
167
  }
147
168
 
@@ -162,9 +183,9 @@ export default {
162
183
  position: absolute;
163
184
  top: calc(100% + 0.25rem);
164
185
  background: #fff;
165
- border: 1px solid var(--bs-gray-300);
166
- border-radius: var(--bs-border-radius);
167
- box-shadow: var(--bs-box-shadow);
186
+ border: 1px solid var(--bs-gray-300, #DBE2E7);
187
+ border-radius: var(--bs-border-radius, 0.375rem);
188
+ box-shadow: var(--bs-box-shadow, 0 0.5rem 1rem rgba(73, 85, 98, 0.15));
168
189
  }
169
190
 
170
191
  .opt-item {
@@ -184,7 +205,8 @@ export default {
184
205
  display: flex;
185
206
  align-items: center;
186
207
  flex-wrap: wrap;
187
- padding: .5rem .5rem .25rem .5rem;
208
+ gap: .25rem;
209
+ padding: .5rem;
188
210
 
189
211
  &.is-disabled:hover {
190
212
  cursor: not-allowed;
@@ -192,22 +214,41 @@ export default {
192
214
  }
193
215
 
194
216
  .new-tag-input {
217
+ height: 32px;
195
218
  all: unset;
196
219
  flex-grow: 1;
197
- margin-bottom: .25rem;
198
- background: var(--bs-gray-100);
220
+ background: var(--bs-gray-100, #f9f9fd);
199
221
  padding: .25rem .75rem;
200
222
  border-radius: 99px;
201
223
  border: 1px solid rgba(#000, 0);
224
+ transition: .2s ease-out;
225
+ border: 1px solid var(--bs-gray-200, #e8edf4);
226
+ outline-width: 3px;
227
+ outline-color: rgba(var(--purple-500-rgb, 100, 122, 241), 0);
228
+ outline-style: solid;
229
+
230
+ &::placeholder {
231
+ color: var(--bs-secondary-color, rgba(103, 120, 137, 0.5));
232
+ }
233
+
234
+ &:focus {
235
+ outline-width: 3px;
236
+ outline-color: rgba(var(--purple-500-rgb, 100, 122, 241), .25);
237
+ outline-style: solid;
238
+ }
202
239
  }
203
240
 
204
241
  .badge.rounded-pill {
205
- margin: 0 .25rem .25rem 0;
206
242
  display: inline-flex;
207
243
  align-items: stretch;
208
244
  overflow: hidden;
245
+ height: 32px;
246
+ padding: .5rem .75rem;
247
+ margin: 0;
209
248
 
210
249
  .tag-text {
250
+ font-size: .75rem;
251
+ font-weight: 500;
211
252
  margin: auto;
212
253
  overflow: hidden;
213
254
  text-overflow: ellipsis;
@@ -221,7 +262,7 @@ export default {
221
262
  display: block;
222
263
  content: ' ';
223
264
  height: 100%;
224
- border-left: 1px solid var(--bs-gray-300);
265
+ border-left: 1px solid var(--bs-gray-300, #DBE2E7);
225
266
  margin: 0 0.25rem;
226
267
  }
227
268
  }
@@ -235,7 +276,7 @@ export default {
235
276
  .material-icons {
236
277
  font-size: 16px;
237
278
  line-height: 16px;
238
- color: var(--bs-purple);
279
+ color: var(--bs-purple, #647AF1);
239
280
  opacity: .5;
240
281
  transition: .3s;
241
282
  }
@@ -249,6 +290,5 @@ export default {
249
290
  }
250
291
  }
251
292
  }
252
-
253
293
  }
254
294
  </style>