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.
- package/ModalFileRepositorySelector.vue +402 -0
- package/common/ModalSetup.js +56 -0
- package/package.json +1 -1
- package/style/scss/Components/ModalFileRepository.scss +365 -0
- package/tagEditor.vue +156 -116
|
@@ -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
|
@@ -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
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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"
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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: .
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|