jobdone-shared-files 1.1.21 → 1.1.23

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,413 @@
1
+ <template>
2
+ <div class="modal fade" :class="subModal ? 'sub-modal' : ''" ref="modalDom" data-bs-backdrop="static" tabindex="-1"
3
+ role="dialog" aria-hidden="true" data-bs-focus="false">
4
+ <div class="modal-dialog modal-xl modal-dialog-scrollable" role="document">
5
+ <div class="modal-content modal-daily-multimedia-images-selector">
6
+ <div class="modal-header">
7
+ <h5 class="modal-title">影音日誌紀錄圖片選擇器</h5>
8
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
9
+ </div>
10
+ <div class="d-flex justify-content-between align-items-center p-2 border-bottom">
11
+ <div class="filter-container">
12
+ <div class="filter-item">
13
+ <div class="form-label-content">
14
+ <label class="form-label">日期區間</label>
15
+ <button v-if="filterDate" class="btn-clear-item" @click='filterDate = ""'></button>
16
+ </div>
17
+ <flatPickr :config="dateConfig" class="form-control" placeholder="記錄日期篩選"
18
+ v-model="filterDate" />
19
+ </div>
20
+ <div class="d-flex">
21
+ <button type="button" class="btn btn-primary mt-auto" @click="applyFilter"><span
22
+ class="material-icons">search</span></button>
23
+ </div>
24
+ </div>
25
+ <div class="btn-group" role="group">
26
+ <button type="button" class="btn"
27
+ :class="{ 'btn-primary': activeTab === 'records', 'btn-outline-primary': activeTab !== 'records', }"
28
+ @click="switchTab('records')"><span
29
+ class="material-icons me-1">view_list</span><span>記錄列表</span></button>
30
+ <button type="button" class="btn"
31
+ :class="{ 'btn-primary': activeTab === 'medias', 'btn-outline-primary': activeTab !== 'medias', }"
32
+ @click="switchTab('medias')"><span
33
+ class="material-icons me-1">view_module</span><span>照片總覽</span></button>
34
+ </div>
35
+ </div>
36
+ <div class="modal-body p-0" ref="scrollDom">
37
+ <template v-if="recordsState.items?.length > 0">
38
+ <div v-show="activeTab === 'records'">
39
+ <table class="table records-preview-table ">
40
+ <colgroup>
41
+ <col class="date-grid">
42
+ <col>
43
+ </colgroup>
44
+ <thead class="thead-default">
45
+ <tr>
46
+ <th class="date-grid">記錄日期</th>
47
+ <th>記錄資訊</th>
48
+ </tr>
49
+ </thead>
50
+ <tbody>
51
+ <template v-for="record in sortedRecords" :key="record.id">
52
+ <tr>
53
+ <td class="date-grid">
54
+ <div class="mb-2">{{ record.date }}</div>
55
+ <button type="button" class="btn btn-sm mt-1"
56
+ :class="allSelectedByRecord.get(record.id) ? 'btn-primary' : 'btn-outline-primary'"
57
+ :disabled="!record.images?.length"
58
+ @click="toggleAllInRecord(record)">
59
+ {{ allSelectedByRecord.get(record.id) ? '取消全選' : `全選 (
60
+ ${record.images?.length ?? 0} )` }}
61
+ </button>
62
+ </td>
63
+ <td>
64
+ <h6 class="mb-1">{{ record.title }}</h6>
65
+ <small class="d-block text-gray-500 mb-2">{{ record.content }}</small>
66
+ <div class="d-flex flex-wrap gap-1 mb-2" v-if="record.tags?.length">
67
+ <span class="d-inline-flex flex-center badge badge-outline-gray"
68
+ v-for="tag in record.tags" :key="tag.label">{{ tag.label }}:{{
69
+ tag.value }}</span>
70
+ </div>
71
+ <div class="records-photos" v-if="record.images?.length">
72
+ <div class="media-item" v-for="(img, imgIdx) in record.images"
73
+ :key="img.id">
74
+ <div class="media-item-body">
75
+ <div class="multimedia-select-content">
76
+ <button type="button" class="select-control"
77
+ :class="{ 'multimedia-is-selected': selectedImageIds.has(img.id) }"
78
+ @click.stop="toggleImage(img)"></button>
79
+ </div>
80
+ <button type="button" class="multimedia-preview-content"
81
+ @click="previewImagesShow(record.images, imgIdx)">
82
+ <img :src="img.thumbnailUrl || img.url"
83
+ :alt="`img-${img.id}`" />
84
+ </button>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </td>
89
+ </tr>
90
+ </template>
91
+ </tbody>
92
+ </table>
93
+ </div>
94
+ <div v-show="activeTab === 'medias'" class="multimedia-list-preview">
95
+ <div class="the-month-content" v-for="theMonth in mediasByMonth" :key="theMonth.monthKey">
96
+ <p class="the-month-content-header">{{ theMonth.monthLabel }}</p>
97
+ <div class="the-month-content-body">
98
+ <template v-for="day in theMonth.days" :key="`${theMonth.monthKey}-${day.dayKey}`">
99
+ <div class="media-item" v-for="(item, itemIdx) in day.items"
100
+ :key="`${theMonth.monthKey}-${day.dayKey}-${item.id}`">
101
+ <p class="media-item-header" v-if="itemIdx === 0">{{ day.dayLabel }}</p>
102
+ <div class="media-item-body">
103
+ <div class="multimedia-select-content">
104
+ <button type="button" class="select-control"
105
+ :class="{ 'multimedia-is-selected': selectedImageIds.has(item.id) }"
106
+ @click.stop="toggleImage(item)"></button>
107
+ </div>
108
+ <button type="button" class="multimedia-preview-content"
109
+ @click="previewImagesShow(day.items, itemIdx)">
110
+ <img :src="item.thumbnailUrl || item.url" :alt="`img-${item.id}`" />
111
+ </button>
112
+ </div>
113
+ </div>
114
+ </template>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </template>
119
+ <div v-else>
120
+ <div class="d-flex flex-column align-items-center justify-content-center py-5 bg-gray-300">
121
+ <span class="material-icons"
122
+ style="font-size: 48px; color: var(--gray-400);">photo_library</span>
123
+ <p class="mt-3 mb-0" style="color: var(--gray-500);">未找到相關影音日誌記錄</p>
124
+ </div>
125
+ </div>
126
+ <div ref="sentinelDom" aria-hidden="true"></div>
127
+ </div>
128
+ <div class="selected-preview-bar" v-if="selectedImages.length">
129
+ <div class="selected-preview-header">已選擇圖片 ( {{ selectedImages.length }} )</div>
130
+ <div class="selected-preview-body">
131
+ <div class="selected-preview-list">
132
+ <div class="media-item" v-for="(img, imgIdx) in selectedImages" :key="img.id">
133
+ <div class="media-item-body">
134
+ <div class="multimedia-select-content">
135
+ <button type="button" class="select-control multimedia-is-selected"
136
+ @click.stop="toggleImage(img)"></button>
137
+ </div>
138
+ <button type="button" class="multimedia-preview-content"
139
+ @click="previewImagesShow(selectedImages, imgIdx)">
140
+ <img :src="img.thumbnailUrl || img.url" :alt="`img-${img.id}`" />
141
+ </button>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ <div class="modal-footer">
148
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
149
+ <button type="button" class="btn btn-primary" @click="submit"
150
+ :disabled="selectedImages.length === 0">確認選擇 (
151
+ {{
152
+ selectedImages.length || '請選擇圖片'
153
+ }} )</button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ <VueEasyLightbox ref="ezLb" :visible="previewImages?.length > 0" :imgs="previewImages" :index="previewImageIndex"
159
+ @hide="previewImagesOnHide" />
160
+ <Loading :is-active="isLoading"></Loading>
161
+ </template>
162
+ <script setup>
163
+ import { ref, reactive, computed, nextTick, onMounted, onBeforeUnmount } from 'vue';
164
+ import { modalShow, modalHide } from "./common/ModalSetup.js";
165
+ import Loading from './common/vueLoadingOverlay.vue';
166
+ import flatPickr from "vue-flatpickr-component";
167
+ import { MandarinTraditional } from "flatpickr/dist/l10n/zh-tw";
168
+ import VueEasyLightbox from 'vue-easy-lightbox';
169
+
170
+ const props = defineProps({
171
+ subModal: {
172
+ type: Boolean,
173
+ default: false,
174
+ },
175
+ fetchFn: {
176
+ type: Function,
177
+ required: true,
178
+ },
179
+ pageSize: {
180
+ type: Number,
181
+ default: 20,
182
+ },
183
+ });
184
+
185
+ const emit = defineEmits(['afterSubmit']);
186
+
187
+ const isLoading = ref(false);
188
+ const modalDom = ref(null);
189
+ const scrollDom = ref(null);
190
+ const sentinelDom = ref(null);
191
+ let scrollObserver = null;
192
+
193
+ // #region Flatpickr
194
+ const dateConfig = {
195
+ altFormat: "Y-m-d",
196
+ dateFormat: "Y-m-d",
197
+ disableMobile: "true",
198
+ mode: "range",
199
+ locale: MandarinTraditional,
200
+ }
201
+ // #endregion Flatpickr
202
+
203
+ // #region 預覽圖片
204
+ const previewImages = ref([]);
205
+ const previewImageIndex = ref(0);
206
+ const previewImagesShow = (images, idx) => {
207
+ previewImageIndex.value = idx;
208
+ previewImages.value = images.map((item) => ({ src: item.url }));
209
+ };
210
+ const previewImagesOnHide = () => {
211
+ previewImages.value = [];
212
+ previewImageIndex.value = 0;
213
+ };
214
+ // #endregion 預覽圖片
215
+
216
+
217
+ const activeTab = ref('records');
218
+ const filterDate = ref('');
219
+ const selectedImages = ref([]);
220
+ // 改用 reactive Set 取代 computed Set。Vue 3 對 reactive 集合提供 per-element 反應式追蹤,
221
+ // template 裡 .has(id) 只會在「該 id 的選取狀態變動」時 invalidate,
222
+ // 不會像 computed Set(每次都回傳新 Set 物件)那樣讓所有 button 的 binding 一起重新評估
223
+ const selectedImageIds = reactive(new Set());
224
+
225
+ const createInitialRecordsState = () => ({
226
+ items: [],
227
+ page: 0,
228
+ hasMore: true,
229
+ initialized: false,
230
+ emptyStreak: 0,
231
+ });
232
+ const recordsState = reactive(createInitialRecordsState());
233
+
234
+ const MAX_EMPTY_STREAK = 5; // 連續多頁全被過濾掉時的安全閥,避免 observer 重複觸發造成無限請求
235
+
236
+ // 失效令牌:每次重置 state 就 +1。in-flight 的 loadMore 開頭會抓快照 myGen,
237
+ // 等 fetch 回來時比對;若 myGen 已落後,代表期間 modal 被關 / 篩選被換,這份結果該丟棄,
238
+ // 避免把舊資料 push 進已被清空的新 state(例如:fetch A 還在飛 → 使用者按篩選 → fetch A 回來污染新列表)
239
+ let loadGeneration = 0;
240
+
241
+ const resetRecordsState = () => { // 重置記錄列表相關狀態(如關閉 modal 或重新套用篩選條件時)
242
+ loadGeneration += 1; // 讓所有 in-flight 的 loadMore 變成「上個世代」,回來後會被丟棄
243
+ isLoading.value = false; // 強制清掉,避免下一個 loadMore 被舊的 isLoading=true 擋住
244
+ Object.assign(recordsState, createInitialRecordsState());
245
+ };
246
+
247
+ // 跨頁載入時 fetchFn 不保證順序,這裡統一在前端依 record.date 由新到舊排序,
248
+ // 讓「記錄列表」與「照片總覽」都吃同一份排序好的資料
249
+ const sortedRecords = computed(() => {
250
+ // ISO 格式(YYYY-MM-DD)字串遞減即等同日期遞減;Array.prototype.sort 為穩定排序,同日期維持原進入順序
251
+ return [...recordsState.items].sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
252
+ });
253
+
254
+ const mediasByMonth = computed(() => { // 將圖片依年月日分類,方便在照片總覽頁籤中呈現
255
+ const months = new Map();
256
+ for (const record of sortedRecords.value) {
257
+ const d = new Date(record.date);
258
+ const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
259
+ const monthLabel = `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`;
260
+ const dayKey = record.date;
261
+ const dayLabel = record.date;
262
+ if (!months.has(monthKey)) months.set(monthKey, { monthKey, monthLabel, days: new Map() });
263
+ const month = months.get(monthKey);
264
+ if (!month.days.has(dayKey)) month.days.set(dayKey, { dayKey, dayLabel, items: [] });
265
+ const day = month.days.get(dayKey);
266
+ for (const img of record.images) {
267
+ day.items.push({ id: img.id, url: img.url, thumbnailUrl: img.thumbnailUrl });
268
+ }
269
+ }
270
+ // 月與日皆以由新到舊呈現;monthKey/dayKey 是 ISO 格式(YYYY-MM、YYYY-MM-DD),字串遞減排序即等同日期遞減
271
+ return Array.from(months.values())
272
+ .sort((a, b) => b.monthKey.localeCompare(a.monthKey))
273
+ .map((m) => ({
274
+ monthKey: m.monthKey,
275
+ monthLabel: m.monthLabel,
276
+ days: Array.from(m.days.values()).sort((a, b) => b.dayKey.localeCompare(a.dayKey)),
277
+ }));
278
+ });
279
+
280
+ const refreshObserver = async () => { // 重新觀察觸底偵測點:當載入完成後若它仍在可視區內(內容沒撐滿),會再次觸發 callback 繼續載入下一頁
281
+ await nextTick();
282
+ if (!scrollObserver || !sentinelDom.value) return;
283
+ scrollObserver.unobserve(sentinelDom.value);
284
+ scrollObserver.observe(sentinelDom.value);
285
+ };
286
+
287
+ const loadMore = async () => { // 載入更多記錄
288
+ if (isLoading.value || !recordsState.hasMore) return;
289
+ const myGen = loadGeneration; // 抓快照;await 期間若被 reset,loadGeneration 會走在前頭,靠這個比對丟棄結果
290
+ try {
291
+ isLoading.value = true;
292
+ const fetchParams = {
293
+ page: recordsState.page + 1,
294
+ pageSize: props.pageSize,
295
+ filters: { date: filterDate.value },
296
+ };
297
+ const result = await props.fetchFn(fetchParams);
298
+ if (myGen !== loadGeneration) return; // 已被 reset,舊資料不能進新 state
299
+ const validItems = (result?.items ?? []).filter((r) => r.images?.length);
300
+ recordsState.items.push(...validItems);
301
+ recordsState.page = fetchParams.page;
302
+ recordsState.hasMore = !!result?.hasMore;
303
+ recordsState.initialized = true;
304
+ recordsState.emptyStreak = validItems.length === 0 ? recordsState.emptyStreak + 1 : 0;
305
+ } catch (err) {
306
+ if (myGen !== loadGeneration) return; // 已被 reset,不該動到新 state 的 hasMore
307
+ recordsState.hasMore = false; // 失敗就停止,避免 observer 重觸發造成無限重試
308
+ console.error('[ModalDailyMultimediaImagesSelector] loadMore failed', err);
309
+ } finally {
310
+ if (myGen === loadGeneration) isLoading.value = false; // 只有同世代才能清;否則新 loadMore 剛把它設成 true,不能誤覆寫
311
+ }
312
+ if (myGen !== loadGeneration) return; // 收尾邏輯(emptyStreak 判斷、refreshObserver)也要保護
313
+ if (recordsState.emptyStreak >= MAX_EMPTY_STREAK) {
314
+ recordsState.hasMore = false;
315
+ return;
316
+ }
317
+ // 統一靠 observer 重觸發:sentinel 仍可見(內容沒撐滿、或本頁全空)就會自動再呼一次 loadMore
318
+ refreshObserver();
319
+ };
320
+
321
+ const switchTab = (tab) => {
322
+ activeTab.value = tab;
323
+ refreshObserver();
324
+ };
325
+
326
+ const applyFilter = async () => {
327
+ resetRecordsState();
328
+ await loadMore();
329
+ };
330
+
331
+ //#region 圖片選取相關
332
+ const toggleImage = (img) => {
333
+ const idx = selectedImages.value.findIndex((m) => m.id === img.id);
334
+ if (idx >= 0) {
335
+ selectedImages.value.splice(idx, 1);
336
+ selectedImageIds.delete(img.id);
337
+ } else {
338
+ selectedImages.value.push({ id: img.id, url: img.url, thumbnailUrl: img.thumbnailUrl, fileName: img.fileName });
339
+ selectedImageIds.add(img.id);
340
+ }
341
+ };
342
+
343
+ const allSelectedByRecord = computed(() => { // 預先算出每筆 record 是否「全選」,template 直接查表,避免在 HTML 呼叫 function
344
+ const map = new Map();
345
+ for (const record of recordsState.items) {
346
+ map.set(
347
+ record.id,
348
+ !!record.images?.length && record.images.every((img) => selectedImageIds.has(img.id)),
349
+ );
350
+ }
351
+ return map;
352
+ });
353
+
354
+ const toggleAllInRecord = (record) => {
355
+ if (!record.images?.length) return;
356
+ if (allSelectedByRecord.value.get(record.id)) {
357
+ const removeIds = new Set(record.images.map((img) => img.id));
358
+ selectedImages.value = selectedImages.value.filter((m) => !removeIds.has(m.id));
359
+ for (const id of removeIds) selectedImageIds.delete(id);
360
+ } else {
361
+ const toAdd = record.images
362
+ .filter((img) => !selectedImageIds.has(img.id))
363
+ .map((img) => ({ id: img.id, url: img.url, thumbnailUrl: img.thumbnailUrl, fileName: img.fileName }));
364
+ selectedImages.value.push(...toAdd);
365
+ for (const m of toAdd) selectedImageIds.add(m.id);
366
+ }
367
+ };
368
+ //#endregion 圖片選取相關
369
+
370
+ // #region 光箱操作相關
371
+ const closeModal = () => {
372
+ selectedImages.value = [];
373
+ previewImages.value = [];
374
+ selectedImageIds.clear();
375
+ filterDate.value = '';
376
+ activeTab.value = 'records';
377
+ resetRecordsState();
378
+ };
379
+
380
+ const show = (preSelected = []) => {
381
+ // 開啟時可帶入已選圖片,讓使用者看到先前的選擇狀態(例如再次開啟時保留勾選)
382
+ selectedImages.value = preSelected.map((img) => ({ id: img.id, url: img.url, fileName: img.fileName }));
383
+ selectedImageIds.clear();
384
+ for (const img of preSelected) selectedImageIds.add(img.id);
385
+ modalShow(modalDom.value, closeModal);
386
+ if (!recordsState.initialized) loadMore();
387
+ };
388
+
389
+ const submit = () => {
390
+ modalHide(modalDom.value);
391
+ // thumbnailUrl 是內部顯示用、不外流;對外帶出 { id, url, fileName }
392
+ emit('afterSubmit', selectedImages.value.map((m) => ({ id: m.id, url: m.url, fileName: m.fileName })));
393
+ };
394
+ // #endregion 光箱操作相關
395
+
396
+ onMounted(() => {
397
+ if (!sentinelDom.value || !scrollDom.value) return;
398
+ scrollObserver = new IntersectionObserver(
399
+ (entries) => {
400
+ if (entries[0]?.isIntersecting) loadMore();
401
+ },
402
+ { root: scrollDom.value, rootMargin: '100px' },
403
+ );
404
+ scrollObserver.observe(sentinelDom.value);
405
+ });
406
+
407
+ onBeforeUnmount(() => {
408
+ scrollObserver?.disconnect();
409
+ scrollObserver = null;
410
+ });
411
+
412
+ defineExpose({ show });
413
+ </script>
@@ -6,8 +6,8 @@
6
6
  selectedPreviewText
7
7
  }}</button>
8
8
  <!-- search input -->
9
- <input class='form-control autocomplete-select-component-keyword-filter-input' :class="[triggerClass, searchInput]"
10
- type="text" ref="keywordFilterInput" v-model="keyword"
9
+ <input class='form-control autocomplete-select-component-keyword-filter-input'
10
+ :class="[triggerClass, searchInput]" type="text" ref="keywordFilterInput" v-model="keyword"
11
11
  :placeholder="searchPlaceholder == '' ? selectedPreviewText : searchPlaceholder" maxlength="50"
12
12
  @keydown.enter="keyboardSelectConfirm($event)" @keydown.up="keyboardSwitch($event, -1)"
13
13
  @keydown.down="keyboardSwitch($event, 1)" @change="keyboardSwitchIndexReset()">
@@ -27,8 +27,8 @@
27
27
  </div>
28
28
  </li>
29
29
  <template v-if="active">
30
- <li v-for="(opt, idx) in filterList" :key="idx" @click.stop="selectConfirm(opt)" :title="opt?.name"
31
- :id="`autocomplete-select-component-opt-item_${idx}`">
30
+ <li v-for="(opt, idx) in filterList" :key="idx" @click.stop="selectConfirm(opt)"
31
+ :title="opt?.name" :id="`autocomplete-select-component-opt-item_${idx}`">
32
32
  <button class="autocomplete-select-component-opt-item" type="button"
33
33
  :class="`${idx == keyboardSwitchIndex ? 'active' : ''} ${optClass}`">
34
34
  <template v-if="htmlOption">
@@ -55,7 +55,7 @@
55
55
  // enum Data & Functions
56
56
 
57
57
  // vue & bootstrap
58
- import { ref, onMounted, onUnmounted, computed, toRaw } from 'vue'
58
+ import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
59
59
 
60
60
  // plugins
61
61
 
@@ -106,7 +106,6 @@ const props = defineProps({
106
106
  default: false
107
107
  },
108
108
 
109
-
110
109
  previewKey: {
111
110
  type: String,
112
111
  default: '',
@@ -140,22 +139,15 @@ const componentContentInput = ref(null)
140
139
  const componentContentList = ref(null)
141
140
  const keywordFilterInput = ref(null)
142
141
 
143
- const valueIsUnselected = computed(()=>{
142
+ const valueIsUnselected = computed(() => {
144
143
  const defaultPlaceholderValue = [null, undefined, 0, '']
145
- let ary =[]
146
- for (let index = 0; index < defaultPlaceholderValue.length; index++) {
147
- const element = defaultPlaceholderValue[index];
148
- if(!props.isUnselectedExtendValue.includes(element)){
149
- ary.push(element)
150
- }
151
- }
152
- return ary || []
144
+ return defaultPlaceholderValue.filter(v => !props.isUnselectedExtendValue.includes(v))
153
145
  })
154
146
  const checkValueIsUnselected = (checkValue) => {
155
- if(valueIsUnselected.value.includes(checkValue)){
147
+ if (valueIsUnselected.value.includes(checkValue)) {
156
148
  return true
157
149
  }
158
- if((typeof checkValue) === 'object' && checkValue !== null && Object.keys(checkValue).length === 0){
150
+ if ((typeof checkValue) === 'object' && checkValue !== null && Object.keys(checkValue).length === 0) {
159
151
  return true
160
152
  }
161
153
  return false
@@ -163,36 +155,110 @@ const checkValueIsUnselected = (checkValue) => {
163
155
 
164
156
  // 是否開啟下拉選單
165
157
  const active = ref(false)
166
- const domPosition = ref({ bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0 })
167
- function activeSelector() {
168
- if (props.disabled) {
169
- return
170
- }
171
- active.value = true
172
- keywordFilterInput.value.focus()
173
- domPosition.value = componentContentInput.value.getBoundingClientRect()
174
- }
175
158
  const listIsOnTop = ref(false)
176
- const positionStyle = computed(() => {
177
- let screenHeight = window.innerHeight;
178
- let info = toRaw(domPosition.value)
179
- listIsOnTop.value = info.top >= (screenHeight - info.bottom)
180
- let str = ''
159
+ const positionStyle = ref('')
160
+
161
+ // 自製定位:position fixed + getBoundingClientRect,不依賴外部套件
162
+ const OFFSET = 4 // 下拉與 input 的間距(px)
163
+
164
+ function updatePosition() {
165
+ if (!componentContentInput.value || !componentContentList.value) return
166
+
167
+ const rect = componentContentInput.value.getBoundingClientRect()
168
+ const listEl = componentContentList.value
169
+ const screenH = window.innerHeight
170
+ const spaceBelow = screenH - rect.bottom
171
+ const spaceAbove = rect.top
172
+ const listH = listEl.scrollHeight || 200
173
+
174
+ // flip: 下方空間不夠且上方比較多 → 顯示在上面
175
+ listIsOnTop.value = spaceBelow < listH && spaceAbove > spaceBelow
176
+
177
+ let style = `position:fixed;left:${rect.left}px;min-width:${rect.width}px;max-width:calc(90vw - ${rect.left}px);`
181
178
  if (listIsOnTop.value) {
182
- str = `bottom:${screenHeight - info.top}px;max-height:calc(${info.top}px - 10%);`
179
+ style += `bottom:${screenH - rect.top + OFFSET}px;max-height:${spaceAbove - OFFSET}px;`
183
180
  } else {
184
- str = `top:${info.bottom}px;max-height:calc(${screenHeight - info.bottom}px - 10%);`
181
+ style += `top:${rect.bottom + OFFSET}px;max-height:${spaceBelow - OFFSET}px;`
185
182
  }
186
- return `${str}left:${info.x}px;max-width:calc(90% - ${info.x}px);min-width:${Math.abs(info.x - info.right)}px;`
187
- })
183
+ positionStyle.value = style
184
+ }
185
+
186
+ // 追蹤監聽器的清理函式
187
+ let cleanupListeners = null
188
+ let visibilityObserver = null
189
+
190
+ function startTracking() {
191
+ // 收集所有可滾動的祖層
192
+ const scrollParents = getScrollParents(componentContentInput.value)
193
+ let rafId = null
194
+ const onUpdate = () => {
195
+ if (rafId) return
196
+ rafId = requestAnimationFrame(() => {
197
+ updatePosition()
198
+ rafId = null
199
+ })
200
+ }
201
+
202
+ scrollParents.forEach(el => el.addEventListener('scroll', onUpdate, { passive: true }))
203
+ window.addEventListener('resize', onUpdate, { passive: true })
204
+
205
+ // 偵測 input 離開可視區域時自動關閉
206
+ visibilityObserver = new IntersectionObserver((entries) => {
207
+ if (!entries[0].isIntersecting && active.value) {
208
+ leave()
209
+ }
210
+ }, { threshold: 0 })
211
+ visibilityObserver.observe(componentContentInput.value)
212
+
213
+ cleanupListeners = () => {
214
+ scrollParents.forEach(el => el.removeEventListener('scroll', onUpdate))
215
+ window.removeEventListener('resize', onUpdate)
216
+ if (visibilityObserver) {
217
+ visibilityObserver.disconnect()
218
+ visibilityObserver = null
219
+ }
220
+ cleanupListeners = null
221
+ }
222
+ }
223
+
224
+ function stopTracking() {
225
+ if (cleanupListeners) cleanupListeners()
226
+ }
227
+
228
+ // 找出所有可滾動的祖層元素
229
+ function getScrollParents(el) {
230
+ const parents = []
231
+ let current = el?.parentElement
232
+ while (current) {
233
+ const style = getComputedStyle(current)
234
+ if (/(auto|scroll|overlay)/.test(style.overflow + style.overflowY + style.overflowX)) {
235
+ parents.push(current)
236
+ }
237
+ current = current.parentElement
238
+ }
239
+ parents.push(window)
240
+ return parents
241
+ }
242
+
243
+ async function activeSelector() {
244
+ if (props.disabled) return
245
+ const wasActive = active.value
246
+ active.value = true
247
+ keywordFilterInput.value.focus()
248
+ if (!wasActive) {
249
+ await nextTick()
250
+ updatePosition()
251
+ startTracking()
252
+ }
253
+ }
188
254
 
189
255
  function leave() {
256
+ stopTracking()
190
257
  listIsOnTop.value = false
191
258
  active.value = false
192
259
  keyword.value = ''
193
- // TODO SCROLL CLOSE
194
260
  keyboardSwitchIndexReset()
195
- keywordFilterInput.value.blur()
261
+ keywordFilterInput.value?.blur()
196
262
  }
197
263
 
198
264
  // 確認傳入選單列表內的選項Type,只檢查第一個,請內容統一,不要傳奇怪的東西進來
@@ -206,7 +272,7 @@ const optItemType = computed(() => {
206
272
  if (!allowOptItemType.includes(type)) {
207
273
  return ''
208
274
  }
209
- if ((sampling instanceof Date) && (sampling instanceof RegExp) && Array.isArray(sampling)) {
275
+ if ((sampling instanceof Date) || (sampling instanceof RegExp) || Array.isArray(sampling)) {
210
276
  return ''
211
277
  }
212
278
  return type?.toLowerCase()
@@ -223,23 +289,14 @@ const filterList = computed(() => {
223
289
  if (keyword.value == '') {
224
290
  return props.opts
225
291
  }
226
- let kwReplace = keyword.value.replace(/[.*+?^${}()|[\]\\]/g, "");
292
+ let kwReplace = keyword.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
293
  let regex = new RegExp(kwReplace, "i");
228
294
  if (optItemType.value === 'object') {
229
- let final = props.opts.filter(i => {
230
- for (var idx = 0; idx < props.filterKeys.length; idx++) {
231
- if (regex.test(i[props.filterKeys[idx].toString()])) {
232
- return i
233
- };
234
- }
235
- })
236
- return final
295
+ return props.opts.filter(i =>
296
+ props.filterKeys.some(key => regex.test(i[key.toString()]))
297
+ )
237
298
  }
238
- return props.opts.filter(i => {
239
- if (regex.test(i)) {
240
- return i
241
- }
242
- })
299
+ return props.opts.filter(i => regex.test(i))
243
300
  })
244
301
 
245
302
  // 從選項列表中挖出整個選中的item,因選中的值(可能是某個key)
@@ -262,6 +319,7 @@ function previewAdjust(itemObj) {
262
319
  let strAry = props.previewKey.split('+')
263
320
  let isUndefined = false
264
321
 
322
+ const undefinedKeys = []
265
323
  let finalStr = strAry.reduce((acc, cur) => {
266
324
  let v = cur.trim()
267
325
  if (v.match(/'[^']+'/g)) {
@@ -269,11 +327,17 @@ function previewAdjust(itemObj) {
269
327
  } else {
270
328
  if (itemObj[v] === undefined) {
271
329
  isUndefined = true
330
+ undefinedKeys.push(v)
272
331
  }
273
332
  return acc + itemObj[v]
274
333
  }
275
334
  }, '')
276
335
  if (isUndefined) {
336
+ console.error(
337
+ `[autocompleteSelect] previewKey 中找不到對應欄位: [${undefinedKeys.join(', ')}],` +
338
+ `物件實際擁有的 key: [${Object.keys(itemObj).join(', ')}]`,
339
+ itemObj
340
+ )
277
341
  return props.isUndefinedHint
278
342
  }
279
343
  return finalStr ? finalStr : props.placeholder
@@ -360,9 +424,10 @@ const searchInput = computed(() => {
360
424
  return ''
361
425
  })
362
426
  function initTrigger(event) {
363
- if (componentContentInput.value.contains(event.target) || componentContentList.value.contains(event.target)) {
427
+ if (!componentContentInput.value) return
428
+ if (componentContentInput.value.contains(event.target) || componentContentList.value?.contains(event.target)) {
364
429
  activeSelector()
365
- } else {
430
+ } else if (active.value) {
366
431
  leave()
367
432
  }
368
433
  }
@@ -372,6 +437,7 @@ onMounted(() => {
372
437
 
373
438
  onUnmounted(() => {
374
439
  window.removeEventListener("click", initTrigger);
440
+ stopTracking()
375
441
  })
376
442
 
377
443
  </script>
@@ -424,10 +490,7 @@ onUnmounted(() => {
424
490
  .autocomplete-select-component-selector-content {
425
491
  visibility: hidden;
426
492
  display: none;
427
- position: fixed;
428
493
  z-index: 2000;
429
- margin-top: 0.25rem;
430
- margin-bottom: 0.25rem;
431
494
  max-height: 76vh;
432
495
  overflow: auto;
433
496
 
package/index.js CHANGED
@@ -12,6 +12,7 @@ import selectPlaceholder from "./common/directives/selectPlaceholder.js";
12
12
  import collapse from "./common/directives/collapse.js";
13
13
  import popovers from "./common/directives/popovers.js";
14
14
  import tooltip from "./common/directives/tooltip.js";
15
+ import ModalDailyMultimediaImagesSelector from "./ModalDailyMultimediaImagesSelector.vue";
15
16
 
16
17
  export default {
17
18
  paginate,
@@ -31,6 +32,7 @@ export {
31
32
  ModalFileRepositorySelector,
32
33
  autocompleteSelect,
33
34
  ProjectContactUserPicker,
35
+ ModalDailyMultimediaImagesSelector,
34
36
 
35
37
  // ProjectManagement
36
38
  projectNavbar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jobdone-shared-files",
3
- "version": "1.1.21",
3
+ "version": "1.1.23",
4
4
  "description": "Shared JS and SCSS for Jobdone Enterprise.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -14,6 +14,8 @@
14
14
  "vue-loading-overlay": "^6.0.3",
15
15
  "bootstrap": "^5.3.8",
16
16
  "dayjs": "^1.11.7",
17
- "vue-easy-lightbox": "^1.16.0"
17
+ "vue-easy-lightbox": "^1.16.0",
18
+ "flatpickr": "^4.6.13",
19
+ "vue-flatpickr-component": "^12.0.0"
18
20
  }
19
21
  }
@@ -0,0 +1,342 @@
1
+ .modal-daily-multimedia-images-selector {
2
+ .modal-title {
3
+ display: flex;
4
+ align-items: center;
5
+ gap: 0.5rem;
6
+
7
+ &::before {
8
+ content: "photo_camera";
9
+ display: block;
10
+ color: var(--bs-primary);
11
+ font-size: 1.25rem;
12
+ line-height: 1.25rem;
13
+ font-family: "Material Icons";
14
+ }
15
+ }
16
+
17
+ .filter-container {
18
+ flex: 1;
19
+ gap: .25rem;
20
+ display: grid;
21
+ grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
22
+
23
+ .filter-item {
24
+ flex: 1;
25
+ }
26
+
27
+ .form-label-content {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: .5rem;
31
+ margin-bottom: .5rem;
32
+ }
33
+
34
+ .form-label {
35
+ font-size: .875rem;
36
+ color: var(--gray-500);
37
+ margin: 0
38
+ }
39
+
40
+ .btn-clear-item {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ font-size: .875rem;
44
+ padding: 0;
45
+ background-color: transparent;
46
+ border: 0;
47
+ color: var(--red-500);
48
+ vertical-align: middle;
49
+ gap: .25rem;
50
+
51
+ &::before {
52
+ font-family: "Material Icons";
53
+ content: "close";
54
+ }
55
+
56
+ &::after {
57
+ content: "清除";
58
+ }
59
+ }
60
+ }
61
+
62
+
63
+
64
+ .media-item {
65
+ display: flex;
66
+ justify-content: flex-end;
67
+ flex-direction: column;
68
+ position: relative;
69
+ padding: 0;
70
+
71
+ .media-item-header {
72
+ margin-bottom: 0.25rem;
73
+ font-size: 0.875rem;
74
+ display: grid;
75
+ grid-template-columns: 3px auto 1fr;
76
+ gap: 0.5rem;
77
+ line-height: 1.25rem;
78
+ color: var(--gray-800);
79
+ padding-top: 0.5rem;
80
+ padding-bottom: 0.5rem;
81
+ margin-bottom: auto;
82
+
83
+ &::before {
84
+ display: block;
85
+ content: " ";
86
+ width: 3px;
87
+ background-color: var(--bs-primary);
88
+ border-radius: 99px;
89
+ }
90
+ }
91
+
92
+ .media-item-body {
93
+ position: relative;
94
+ padding: 0;
95
+ transition: 0.1s ease-out;
96
+
97
+ &:has(.multimedia-is-selected) {
98
+ border: 3px solid rgba(var(--bs-primary-rgb), 0.8);
99
+ border-radius: calc(var(--bs-border-radius) + 3px);
100
+ background-color: #fff;
101
+ transform: scale(0.95);
102
+
103
+ .multimedia-preview-content {
104
+ transform: scale(0.95);
105
+ }
106
+ }
107
+ }
108
+
109
+ .multimedia-select-content {
110
+ position: absolute;
111
+ width: 100%;
112
+ width: -webkit-fill-available;
113
+
114
+ .select-control {
115
+ border: 0;
116
+ background-color: transparent;
117
+ top: 0;
118
+ right: 0;
119
+ padding: 0.5rem;
120
+ z-index: 10;
121
+ margin: 0;
122
+ position: absolute;
123
+ user-select: none;
124
+ transition: opacity 0.2s ease-out;
125
+
126
+ &::before {
127
+ content: "check";
128
+ display: block;
129
+ position: absolute;
130
+ margin: auto;
131
+ color: #fff;
132
+ font-size: 1rem;
133
+ line-height: 1rem;
134
+ opacity: 0.5;
135
+ font-family: "Material Icons";
136
+ z-index: 10;
137
+ width: 1rem;
138
+ height: 1rem;
139
+ left: calc(50% - 0.5rem);
140
+ top: calc(50% - 0.5rem);
141
+ transform: translateY(-1px);
142
+ transition: opacity 0.2s ease-out;
143
+ }
144
+
145
+ &::after {
146
+ opacity: 0.8;
147
+ border: 2px solid;
148
+ border-color: #ffffff85;
149
+ content: " ";
150
+ display: block;
151
+ width: 1.75rem;
152
+ height: 1.75rem;
153
+ background-color: rgba(0, 0, 0, 0.2);
154
+ border-radius: var(--bs-border-radius);
155
+ backdrop-filter: blur(2px);
156
+ transition: 0.2s ease-out;
157
+ }
158
+
159
+ &:hover {
160
+ cursor: pointer;
161
+
162
+ &::before {
163
+ opacity: 1;
164
+ }
165
+ }
166
+
167
+ &.multimedia-is-selected {
168
+ opacity: 1;
169
+
170
+ &::before {
171
+ opacity: 1;
172
+ }
173
+
174
+ &::after {
175
+ opacity: 1;
176
+ background-color: var(--bs-primary);
177
+ border-color: #fff;
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ .multimedia-preview-content {
184
+ position: relative;
185
+ display: block;
186
+ border: 0;
187
+ background-color: var(--purple-100);
188
+ width: 100%;
189
+ padding-top: 100%;
190
+ overflow: hidden;
191
+ border-radius: var(--bs-border-radius);
192
+
193
+ img {
194
+ transition: 0.3s ease-out;
195
+ border-radius: var(--bs-border-radius);
196
+ width: 100%;
197
+ height: 100%;
198
+ object-fit: cover;
199
+ position: absolute;
200
+ top: 0;
201
+ left: 0;
202
+ }
203
+ }
204
+
205
+ &:hover {
206
+ cursor: pointer;
207
+
208
+ img {
209
+ transform: scale(1.05);
210
+ }
211
+ }
212
+ }
213
+
214
+ .records-preview-table {
215
+ table-layout: fixed;
216
+
217
+ thead {
218
+ position: sticky;
219
+ top: 0;
220
+ z-index: 20;
221
+ }
222
+
223
+ .date-grid {
224
+ text-align: center;
225
+ vertical-align: middle;
226
+ width: 8rem;
227
+ border-right: 1px solid var(--gray-300);
228
+ }
229
+
230
+ .records-photos {
231
+ display: flex;
232
+ flex-wrap: wrap;
233
+ gap: 0.5rem;
234
+ padding: .75rem 1rem;
235
+ background: var(--gray-100);
236
+ border-radius: var(--bs-border-radius);
237
+
238
+ .media-item {
239
+ background-color: #fff;
240
+ border-radius: var(--bs-border-radius);
241
+ border: 3px solid #fff;
242
+ width: 5rem;
243
+
244
+ .select-control {
245
+ // 觸控區域調整,兼具勾選便利性和預覽操作的流暢性
246
+ padding: 0.25rem 0.25rem 0.5rem 0.5rem;
247
+
248
+ &::before {
249
+ left: calc(50% - 0.4rem);
250
+ top: calc(50% - 0.6rem);
251
+
252
+ }
253
+ }
254
+
255
+ &:has(.multimedia-is-selected) {
256
+ border-radius: var(--bs-border-radius-xl);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ .multimedia-list-preview{
262
+ padding: 0 1rem;
263
+ }
264
+ .the-month-content {
265
+ margin-bottom: 2rem;
266
+
267
+ .the-month-content-header {
268
+ font-size: 1.25rem;
269
+ border-bottom: 1px solid var(--gray-300);
270
+ padding-top: 0.75rem;
271
+ padding-bottom: 0.5rem;
272
+ margin-bottom: 0.5rem;
273
+ position: sticky;
274
+ top: 0;
275
+ backdrop-filter: blur(2px);
276
+ background: rgba(255, 255, 255, 0.85);
277
+ z-index: 10;
278
+ }
279
+
280
+ .the-month-content-body {
281
+ display: grid;
282
+ gap: 0.25rem;
283
+ grid-template-columns: repeat(auto-fill, minmax(8.6rem, 1fr));
284
+ }
285
+
286
+ }
287
+
288
+ .selected-preview-bar {
289
+ display: flex;
290
+ flex-direction: column;
291
+ border-top: 1px solid var(--gray-300);
292
+ background-color: var(--gray-100);
293
+ max-height: 25vh;
294
+ overflow: hidden;
295
+ flex-shrink: 0;
296
+
297
+ >* {
298
+ padding: 0.5rem 1rem;
299
+ }
300
+
301
+ .selected-preview-header {
302
+ border-bottom: 1px solid var(--gray-300);
303
+ font-size: 0.875rem;
304
+ color: var(--gray-600);
305
+ flex-shrink: 0;
306
+ }
307
+
308
+ .selected-preview-body {
309
+ overflow: auto;
310
+ background: var(--gray-400);
311
+ }
312
+
313
+ .selected-preview-list {
314
+ display: flex;
315
+ flex-wrap: wrap;
316
+ gap: 0.25rem;
317
+
318
+ .media-item {
319
+ height: 6rem;
320
+ width: 6rem;
321
+ overflow: hidden;
322
+ }
323
+
324
+ .media-item-body {
325
+ position: relative;
326
+ padding: 0;
327
+ transition: 0.1s ease-out;
328
+
329
+ &:has(.multimedia-is-selected) {
330
+ transform: scale(1);
331
+ border: 3px solid #fff;
332
+
333
+ .multimedia-preview-content {
334
+ transform: scale(1);
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ }
341
+ }
342
+