jobdone-shared-files 1.1.22 → 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>
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.22",
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
+