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>
|
package/autocompleteSelect.vue
CHANGED
|
@@ -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'
|
|
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)"
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
179
|
+
style += `bottom:${screenH - rect.top + OFFSET}px;max-height:${spaceAbove - OFFSET}px;`
|
|
183
180
|
} else {
|
|
184
|
-
|
|
181
|
+
style += `top:${rect.bottom + OFFSET}px;max-height:${spaceBelow - OFFSET}px;`
|
|
185
182
|
}
|
|
186
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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.
|
|
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
|
+
|