ch3chi-commons-vue 1.8.0 → 1.8.1

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.
Files changed (61) hide show
  1. package/package.json +2 -1
  2. package/src/api/ApiService.ts +869 -0
  3. package/src/auth/AuthorizationService.ts +138 -0
  4. package/src/auth/PermissionDescriptor.ts +99 -0
  5. package/src/auth/keys.ts +5 -0
  6. package/src/components/CAlert.vue +188 -0
  7. package/src/components/CAlertDefine.ts +20 -0
  8. package/src/components/CBSToast.vue +119 -0
  9. package/src/components/CGlobalSpinner.vue +84 -0
  10. package/src/components/CImage.vue +67 -0
  11. package/src/components/CRowCheckBox.vue +75 -0
  12. package/src/components/CRowTextInput.vue +27 -0
  13. package/src/components/CTable.vue +524 -0
  14. package/src/components/CTableDefine.ts +566 -0
  15. package/src/components/CTableTD.vue +28 -0
  16. package/src/components/HasPermission.vue +28 -0
  17. package/src/components/form/CChangePasswordFormField.vue +146 -0
  18. package/src/components/form/CCheckBoxFormField.vue +91 -0
  19. package/src/components/form/CCheckBoxPlatFormField.vue +94 -0
  20. package/src/components/form/CDateFormField.vue +149 -0
  21. package/src/components/form/CDateQueryField.vue +111 -0
  22. package/src/components/form/CDateRangeFormField.vue +138 -0
  23. package/src/components/form/CFilePickerFormField.vue +471 -0
  24. package/src/components/form/CRadioFormField.vue +62 -0
  25. package/src/components/form/CRadioPlatFormField.vue +67 -0
  26. package/src/components/form/CSelectFormField.vue +175 -0
  27. package/src/components/form/CTextAreaFormField.vue +84 -0
  28. package/src/components/form/CTextInputFormField.vue +99 -0
  29. package/src/components/form/CTinyMCEEditorFormField.vue +99 -0
  30. package/src/components/form/SCTextInputFormField.vue +129 -0
  31. package/src/composables/useCheckBoxFormField.ts +126 -0
  32. package/src/composables/useRadioFormField.ts +106 -0
  33. package/src/directive/CBootstrapDirective.ts +83 -0
  34. package/src/directive/CDateFormatterDirective.ts +37 -0
  35. package/src/directive/CFTurnstileDirective.ts +46 -0
  36. package/src/directive/CFormDirective.ts +57 -0
  37. package/src/directive/PermissionDirective.ts +102 -0
  38. package/src/env.d.ts +19 -0
  39. package/src/index.ts +83 -0
  40. package/src/model/BSFieldStyleConfig.ts +349 -0
  41. package/src/model/BaseDictionary.ts +86 -0
  42. package/src/model/BaseFormDataModel.ts +623 -0
  43. package/src/model/BaseListViewModel.ts +392 -0
  44. package/src/model/CBSModalViewModel.ts +91 -0
  45. package/src/model/CFileDataModel.ts +181 -0
  46. package/src/model/CImageViewModel.ts +34 -0
  47. package/src/model/CMenuItem.ts +199 -0
  48. package/src/model/EmailReceiverDataModel.ts +149 -0
  49. package/src/model/EmptyDataModel.ts +25 -0
  50. package/src/model/FormOptions.ts +112 -0
  51. package/src/model/LoginDataModel.ts +51 -0
  52. package/src/model/PasswordDataModel.ts +70 -0
  53. package/src/model/QueryParameter.ts +310 -0
  54. package/src/model/SessionUser.ts +110 -0
  55. package/src/model/ShowMessageDataModel.ts +69 -0
  56. package/src/model/TokenUser.ts +157 -0
  57. package/src/stores/FormDataStore.ts +73 -0
  58. package/src/stores/ViewStore.ts +701 -0
  59. package/src/stores/VueSessionStoreInstaller.ts +22 -0
  60. package/src/types/turnstile.d.ts +8 -0
  61. package/src/utils/CToolUtils.ts +133 -0
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+
3
+ import {ImgHTMLAttributes} from "@vue/runtime-dom";
4
+ import {CImageViewModel} from "../model/CImageViewModel";
5
+
6
+ const props = defineProps< /* @vue-ignore */ ImgHTMLAttributes & {
7
+ src?: string; // 圖片來源
8
+ alt?: string; // 替代文字
9
+ width?: string | number; // 圖片寬度
10
+ height?: string | number; // 圖片高度
11
+ className?: string; // 額外的 CSS 類別
12
+ loading?: 'lazy' | 'eager'; // 圖片加載方式
13
+ onLoad?: (event: Event) => void; // 圖片加載完成
14
+ onError?: (event: Event | string) => void; // 圖片加載錯
15
+ }>();
16
+
17
+ const imgViewModel = new CImageViewModel({
18
+ onLoad: props.onLoad,
19
+ onError: props.onError,
20
+ });
21
+
22
+ const attributes = Object.assign({
23
+ src : props.src || imgViewModel.placeholderImage,
24
+ alt : props.alt || 'Image',
25
+ loading : props.loading || 'lazy',
26
+ className: props.className || '',
27
+ }, props);
28
+
29
+
30
+ </script>
31
+
32
+ <template>
33
+ <img v-bind="attributes"
34
+ class="img-thumbnail c-img-thumbnail fade-in-image"
35
+ @load="imgViewModel.onLoadHandler"
36
+ @error="imgViewModel.onErrorHandler" />
37
+ </template>
38
+
39
+ <style scoped>
40
+ /* 圖片樣式 */
41
+ .c-img-thumbnail {
42
+ width: 100%;
43
+ max-width: 200px;
44
+ height: 100%;
45
+ object-fit: scale-down;
46
+ }
47
+ .c-img-thumbnail.fade-in-image {
48
+ opacity: 0;
49
+ transition: opacity 0.75s ease-in-out; /* 過渡效果 */
50
+ }
51
+ .c-img-thumbnail.fade-in-image.loaded {
52
+ opacity: 1; /* 載入完成後顯示圖片 */
53
+ }
54
+
55
+ /* 圖片載入失敗時的樣式 */
56
+ .c-img-thumbnail.error {
57
+ width: 150px;
58
+ background-color: #f3f3f3;
59
+ /* 使用 SVG data URI 顯示一個預設的圖示 */
60
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23aaa'%3e%3cpath d='M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z'/%3e%3c/svg%3e");
61
+ background-repeat: no-repeat;
62
+ background-position: center;
63
+ background-size: 40%;
64
+ border: 1px dashed #ddd;
65
+ }
66
+
67
+ </style>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+
3
+ import type {ComputedRef} from "vue";
4
+ import {computed} from "vue";
5
+ import _ from "lodash";
6
+
7
+ const props = defineProps<{
8
+ align?: 'left' | 'center' | 'right';
9
+ rowData: Record<string, any>;
10
+ cancelable?: boolean; // 是否允許取消選取 (當有 disableComputed 時有用)
11
+ disableComputed?: ComputedRef<boolean>;
12
+ readMode?: boolean; // 是否為閱讀模式,只會顯示由 icon 表示的 checkbox
13
+ }>();
14
+
15
+ /// ----- default props -----
16
+ // define event emits
17
+ const emit = defineEmits(['update:checked']);
18
+ // disable 的 computed
19
+ const useDisabledAttr = computed(() => {
20
+ // 當有給予 disableComputed 的情況下
21
+ if (!_.isNil(props.disableComputed)) {
22
+ // 優先權最高:當有 cancelable 為 true 時
23
+ // 需要查看 rowData.checked 狀態
24
+ // 當為 true 時,則不 disable checkbox
25
+ // 當為 false 時,則使用 disableComputed 的值
26
+ if (props.cancelable === true) {
27
+ return !props.rowData.checked && props.disableComputed?.value;
28
+ }
29
+ // 單純使用 disableComputed 的值
30
+ return props.disableComputed?.value;
31
+ }
32
+ return false;
33
+ });
34
+
35
+ const useAlignCss = computed(() => {
36
+ switch (props.align) {
37
+ case 'center':
38
+ return 'justify-content-center';
39
+ case 'right':
40
+ return 'text-end';
41
+ case 'left':
42
+ default:
43
+ return 'text-start';
44
+ }
45
+ });
46
+
47
+ // ----- event handler -----
48
+ function onChange(event: Event) {
49
+ const val = (event.target as HTMLInputElement).checked;
50
+ emit('update:checked', val, event);
51
+ }
52
+ </script>
53
+
54
+ <template>
55
+ <template v-if="props.readMode">
56
+ <div class="d-flex align-items-center" :class="useAlignCss">
57
+ <font-icon
58
+ :icon="rowData.checked ? 'square-check' : 'square'"
59
+ :class="['c-checkbox-icon', 'text-secondary']"
60
+ />
61
+ </div>
62
+ </template>
63
+ <template v-else>
64
+ <div class="form-check form-check-inline d-flex align-items-center" :class="useAlignCss">
65
+ <input type="checkbox" class="form-check-input c-table-checkbox"
66
+ :disabled="useDisabledAttr"
67
+ :checked="rowData.checked"
68
+ @change="onChange"/>
69
+ </div>
70
+ </template>
71
+ </template>
72
+
73
+ <style scoped>
74
+
75
+ </style>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+
3
+ const props = defineProps<{
4
+ rowData: Record<string, any>
5
+ dataName: string
6
+ }>();
7
+ const emit = defineEmits(['update:textInput']);
8
+
9
+
10
+ // ----- event handler -----
11
+ function onChange(event: Event) {
12
+ const val = (event.target as HTMLInputElement).value;
13
+ emit('update:textInput', val);
14
+ }
15
+
16
+ </script>
17
+
18
+ <template>
19
+ <input type="text" class="form-control c-table-textInput"
20
+ :value="rowData[dataName]"
21
+ @change="onChange"
22
+ />
23
+ </template>
24
+
25
+ <style scoped>
26
+
27
+ </style>
@@ -0,0 +1,524 @@
1
+ <script setup lang="ts">
2
+ import {ComponentPublicInstance, computed, nextTick, onBeforeUnmount, onMounted, reactive, Reactive, ref} from 'vue';
3
+ import {BaseFormDataModel} from "../model/BaseFormDataModel";
4
+ import {QueryPage, QueryParameter} from "../model/QueryParameter";
5
+ import {CTableColumn, CTableColumnType, CTableConfig} from "./CTableDefine";
6
+ import CTableTD from "./CTableTD.vue";
7
+ import {v4 as uuidv4} from 'uuid';
8
+ import {Dropdown} from "bootstrap";
9
+ import _ from 'lodash';
10
+
11
+ // ----- properties -----
12
+ // Define the type of query parameter that this component will accept
13
+ type CTableProps<T extends QueryParameter, V extends BaseFormDataModel> = {
14
+ queryParam?: Reactive<T>;
15
+ columns: CTableColumn[];
16
+ dataList?: V[];
17
+ disablePagination?: boolean;
18
+ multiSort?: boolean; // 多重排序
19
+ pageSizeOptions?: number[];
20
+ styleConfig?: Record<string, any>; // Additional style configurations
21
+ }
22
+ // Define the props for the CTable component
23
+ const props = defineProps<CTableProps<QueryParameter,BaseFormDataModel>>();
24
+
25
+ // ----- emits -----
26
+ // Define the events that this component will emit
27
+ const emit = defineEmits(['page-change', 'sort-change', 'table-action']);
28
+
29
+ // ----- table columns -----
30
+ const tableStyleConfig = CTableConfig.getTableStyleConfig();
31
+ // 根據欄位設定,回傳排序圖示的 class name
32
+ const sortIconClsName = (column: CTableColumn): string => {
33
+ const isSortable = column.sortConfig?.sortable ?? false;
34
+ if (!isSortable) {
35
+ return '';
36
+ }
37
+ // if 可以多重排序,則使用多重排序操作,在這邊先不處理
38
+ if(props.multiSort) {
39
+ return '';
40
+ }
41
+ // 查看目前的排序狀態,排序狀態是從 queryParam 中取得
42
+ const queryParam = props.queryParam;
43
+ if (!queryParam) {
44
+ return '';
45
+ }
46
+ const sortKey = column.sortConfig?.key;
47
+ if (!sortKey) {
48
+ return '';
49
+ }
50
+ const sortItem = queryParam.sort?.find(item => item.field === sortKey);
51
+ let sortIconClassArr = ['c-sort-icon'];
52
+ if(!sortItem) {
53
+ // 該欄位沒有排序,呈現未排序圖示
54
+ sortIconClassArr.push(tableStyleConfig.sortIconNone || '');
55
+ } else if(_.toUpper(sortItem.direction) === 'ASC') {
56
+ // 根據排序方向,回傳對應的圖示 class name
57
+ sortIconClassArr.push('c-arrow-up');
58
+ sortIconClassArr.push(tableStyleConfig.sortIconAsc || '');
59
+ } else if(_.toUpper(sortItem.direction) === 'DESC') {
60
+ // 根據排序方向,回傳對應的圖示 class name
61
+ sortIconClassArr.push('c-arrow-down');
62
+ sortIconClassArr.push(tableStyleConfig.sortIconDesc || '');
63
+ }
64
+ return sortIconClassArr.join(' ');
65
+ };
66
+
67
+ // 從 props 中獲取表格欄位,並給予 emit 事件發射器物件
68
+ const useColumnArray = props.columns.map((column) => {
69
+ // 如果傳入的是 plain object 而不是 CTableColumn 的實例,則包裝成 CTableColumn
70
+ let columnInstance: CTableColumn;
71
+ if (column instanceof CTableColumn) {
72
+ columnInstance = column;
73
+ } else {
74
+ columnInstance = new CTableColumn(column as any);
75
+ }
76
+
77
+ // 如果是動作類型的欄位
78
+ if (columnInstance.type === CTableColumnType.Action) {
79
+ // 為每個 actionItem 添加 emit 方法
80
+ columnInstance.actionList?.forEach(actionItem => {
81
+ actionItem.emit = emit as (event: string, ...args: any[]) => void;
82
+ })
83
+ }
84
+ return columnInstance;
85
+ });
86
+
87
+ // ----- table row -----
88
+ // 判斷是否有資料列表
89
+ const hasAnyData = computed(() => {
90
+ return props.dataList && props.dataList.length > 0;
91
+ });
92
+
93
+ // ----- pagination 元件 -----
94
+ const usePageSizeOptions = props.pageSizeOptions || CTableConfig.pageSizeOptions;
95
+
96
+ // Generate a unique ID for the page size select element
97
+ const showPagination = computed(() => {
98
+ return _.get(props, 'disablePagination', false) == false && hasAnyData.value;
99
+ });
100
+
101
+ // 分頁樣式變數
102
+ const paginationStyleVars = computed(() => {
103
+ return CTableConfig.getPaginationStyleVars();
104
+ });
105
+ // 表格樣式變數
106
+ const tableStyleVars = computed(() => {
107
+ return CTableConfig.getTableStyleVars();
108
+ });
109
+ // 合併所有樣式變數
110
+ const allStyleVars = computed(() => {
111
+ return {
112
+ ...paginationStyleVars.value,
113
+ ...tableStyleVars.value,
114
+ };
115
+ });
116
+ // 分頁 icon
117
+ const {previousIcon, nextIcon, ellipsisIcon} = CTableConfig.getPaginationStyle();
118
+
119
+ const pageSizeSelectId = `c-pageSize-${uuidv4()}`;
120
+ // Create a reactive page model for pagination
121
+ const pageModel = props.queryParam?.page || reactive(new QueryPage());
122
+ // 偵測 pageModel.pageSize 是否在 usePageSizeOptions 中,如果不在,則將 pageModel.pageSize 設定為 usePageSizeOptions 的第一個值
123
+ if(!usePageSizeOptions.includes(pageModel.pageSize)) {
124
+ pageModel.pageSize = usePageSizeOptions[0];
125
+ }
126
+
127
+ // 建立省略號的 Html Element 的引用,這邊會用到多個省略號按鈕,所以使用 Record<number, HTMLElement | null> 來存儲每個按鈕的引用
128
+ const pageEllipsisRef = ref<Record<number, HTMLElement | null>>({});
129
+ // 前往特定頁數的輸入值
130
+ const pageGoToInputValue = ref('');
131
+
132
+ // ----- event handler -----
133
+ // 點擊排序欄位事件
134
+ function doSortColumn(column: CTableColumn) {
135
+ const isSortable = column.sortConfig?.sortable ?? (column as any).sortable ?? false;
136
+ const sortKey = column.sortConfig?.key ?? (column as any).sortKey;
137
+ const defaultDirection = column.sortConfig?.direction ?? (column as any).sortDirection ?? 'ASC';
138
+
139
+ if (!isSortable || !sortKey) {
140
+ return;
141
+ }
142
+ // 查看目前的排序狀態,排序狀態是從 queryParam 中取得
143
+ const queryParam = props.queryParam;
144
+ if (!queryParam) {
145
+ return '';
146
+ }
147
+ const sortItem = queryParam.sort?.find(item => item.field === sortKey);
148
+ // 如果已經有排序,則切換排序方向
149
+ if (sortItem) {
150
+ sortItem.direction = _.toUpper(sortItem.direction) === 'ASC' ? 'DESC' : 'ASC';
151
+ } else {
152
+ // TODO 處理當有多個 sort key 時的行為
153
+ // 如果沒有排序,則新增排序項目,預設為 ASC
154
+ // 清空原本的排序項目
155
+ queryParam.sort?.splice(0, queryParam.sort.length);
156
+ // 以下為只處理單一 sort key 的行為
157
+ queryParam.sort?.push({
158
+ field: sortKey as string,
159
+ direction: defaultDirection || 'ASC'
160
+ });
161
+ }
162
+ // 發出 table-action 事件,帶入排序欄位資訊
163
+ emit('sort-change', {
164
+ column: column,
165
+ sort: queryParam.sort
166
+ });
167
+ }
168
+
169
+ // 每頁筆數改變事件
170
+ function doPageSizeChange(event: Event) {
171
+ emit('page-change', pageModel);
172
+ }
173
+ function doPageNumberChange(toPageIndex: number) {
174
+ if(pageModel.pageIndex == toPageIndex) {
175
+ return;
176
+ }
177
+ pageModel.pageIndex = toPageIndex;
178
+ emit('page-change', pageModel);
179
+ }
180
+ const pageActionMap = {
181
+ first: () => {
182
+ doPageNumberChange(0)
183
+ },
184
+ previous: () => {
185
+ if (pageModel.pageIndex > 0) {
186
+ doPageNumberChange(pageModel.pageIndex - 1);
187
+ }
188
+ },
189
+ next: () => {
190
+ if (pageModel.pageIndex < pageModel.totalPage - 1) {
191
+ doPageNumberChange(pageModel.pageIndex + 1);
192
+ }
193
+ },
194
+ last: () => {
195
+ doPageNumberChange(pageModel.totalPage - 1);
196
+ }
197
+ }
198
+ function onPageNumberChange(event: Event) {
199
+ emit('page-change', pageModel);
200
+ }
201
+ // Dropdown GoToPage Dialog
202
+ function doShowGoToPageDialog(event: Event) {
203
+ pageGoToInputValue.value = '';
204
+ const dropdownInstance = Dropdown.getInstance(event.currentTarget as HTMLElement);
205
+ if(dropdownInstance && _.has(dropdownInstance, '_menu')) {
206
+ dropdownInstance.toggle();
207
+ }
208
+ }
209
+
210
+ // ----- Dropdown Dialog -----
211
+ // Set the reference for the dropdown element
212
+ function setDropdownRef(el: Element | ComponentPublicInstance | null, key: number) {
213
+ if(_.isNil(el) || !(el instanceof HTMLElement)) {
214
+ return;
215
+ }
216
+ pageEllipsisRef.value[key] = el;
217
+ nextTick(() => {
218
+ Dropdown.getOrCreateInstance(el, {
219
+ autoClose: true
220
+ });
221
+ })
222
+ }
223
+ // Hide all dropdown GoToPage
224
+ function hideAllDropdownGoToPage() {
225
+ for(const key in pageEllipsisRef.value) {
226
+ const el = pageEllipsisRef.value[key];
227
+ if(!el) {
228
+ continue;
229
+ }
230
+ const dropdownInstance = Dropdown.getInstance(el);
231
+ dropdownInstance?.hide();
232
+ el.blur();
233
+ }
234
+ }
235
+
236
+ // GoToPage Action 在送出特定頁數觸發
237
+ function doGoToPage(event: Event) {
238
+ // close the dropdown menu
239
+ hideAllDropdownGoToPage();
240
+ const val = parseInt(pageGoToInputValue.value);
241
+ if(isNaN(val) || val < 1 || val > pageModel.totalPage) {
242
+ return;
243
+ }
244
+ doPageNumberChange(val - 1);
245
+ // clean pageGoToInputValue
246
+ pageGoToInputValue.value = '';
247
+ }
248
+
249
+ // Hide dropdown dialog when clicking outside
250
+ function doHideDropdownDialog(event: MouseEvent) {
251
+ // 從 pageEllipsisRef 中獲取所有的 dropdown 元素
252
+ for(const key in pageEllipsisRef.value) {
253
+ const el = pageEllipsisRef.value[key];
254
+ if(!el) {
255
+ continue;
256
+ }
257
+ // 檢查點擊事件是否在 dropdown 元素內
258
+ const parentNode = el.parentNode as HTMLElement;
259
+ if (parentNode.contains(event.target as Node)) {
260
+ // 如果點擊在 dropdown 元素內,則不執行任何操作
261
+ return;
262
+ }
263
+ // 如果點擊在 dropdown 元素外,則關閉 dropdown
264
+ const dropdownInstance = Dropdown.getInstance(el);
265
+ dropdownInstance?.hide();
266
+ el.blur();
267
+ }
268
+ }
269
+
270
+ // ----- LifeCycle -----
271
+ onMounted(() => {
272
+ // 在文件上監聽點擊事件,以便關閉 dropdown dialog
273
+ nextTick(() => {
274
+ document.addEventListener('click', doHideDropdownDialog);
275
+ });
276
+ });
277
+
278
+ onBeforeUnmount(() => {
279
+ // 移除文件上的點擊事件監聽器
280
+ document.removeEventListener("click", doHideDropdownDialog);
281
+ })
282
+
283
+ </script>
284
+
285
+ <template>
286
+ <div class="content-table mt-3 mb-3" :style="allStyleVars">
287
+ <div class="content-table-body mb-3">
288
+ <table class="table table-striped table-hover">
289
+ <thead>
290
+ <tr>
291
+ <th scope="col" v-for="columnItem in useColumnArray"
292
+ :key="columnItem.id"
293
+ :style="{ width: columnItem.width || 'auto' }"
294
+ :class="columnItem.thClass()"
295
+ @click.prevent.stop="doSortColumn(columnItem)">
296
+ <template v-if="columnItem.sortConfig?.sortable">
297
+ <div class="d-flex align-items-center justify-content-between">
298
+ <span>{{ columnItem.text }}</span>
299
+ <i :class="sortIconClsName(columnItem)"></i>
300
+ </div>
301
+ </template>
302
+ <template v-else>
303
+ {{ columnItem.text }}
304
+ </template>
305
+ </th>
306
+ </tr>
307
+ </thead>
308
+ <tbody>
309
+ <template v-if="hasAnyData">
310
+ <tr v-for="dataItem in props.dataList" :key="dataItem.rowId">
311
+ <td v-for="columnItem in useColumnArray" :key="columnItem.id"
312
+ :class="columnItem.tdClass()">
313
+ <CTableTD :column-item="columnItem" :data-item="dataItem"/>
314
+ </td>
315
+ </tr>
316
+ </template>
317
+ <template v-else>
318
+ <tr>
319
+ <td colspan="100%" class="text-center">
320
+ <em class="text-muted">沒有資料</em>
321
+ </td>
322
+ </tr>
323
+ </template>
324
+ </tbody>
325
+ </table>
326
+ </div>
327
+ <div v-if="showPagination" class="content-table-footer py-3 w-100">
328
+ <div class="d-flex flex-column flex-md-row justify-content-between align-items-center mx-3">
329
+ <!-- 每頁筆數下拉選單 -->
330
+ <div>
331
+ <label :for="pageSizeSelectId" class="form-label me-2 mb-0">每頁筆數</label>
332
+ <select :id="pageSizeSelectId" class="form-select d-inline-block w-auto"
333
+ v-model="pageModel.pageSize" @change="doPageSizeChange">
334
+ <option v-for="sizeItem in usePageSizeOptions" :key="sizeItem" :value="sizeItem">
335
+ {{sizeItem}}
336
+ </option>
337
+ </select>
338
+ </div>
339
+ <!-- 分頁元件 -->
340
+ <nav class="table-pagination-nav">
341
+ <ul class="pagination mb-0">
342
+ <li class="page-item" v-if="pageModel.hasPreviousPage()">
343
+ <button class="page-link c-page-action"
344
+ title="前一頁" aria-label="前一頁"
345
+ @click="pageActionMap.previous">
346
+ <i :class="previousIcon"></i>
347
+ </button>
348
+ </li>
349
+ <li class="page-item" v-for="pageItem in pageModel.pageRange()"
350
+ :key="pageItem"
351
+ :class="{'active': (pageModel.pageIndex === pageItem)}">
352
+ <template v-if="pageItem > -1">
353
+ <button class="page-link"
354
+ @click="doPageNumberChange(pageItem)">
355
+ {{pageItem + 1}}
356
+ </button>
357
+ </template>
358
+ <template v-else-if="pageItem <= -1">
359
+ <div class="dropdown d-inline">
360
+ <button :id="`goToPageDropdown-${pageItem}`" class="page-link" type="button" aria-expanded="false"
361
+ :ref="el => setDropdownRef(el, pageItem)" @click="doShowGoToPageDialog">
362
+ <i :class="ellipsisIcon"></i>
363
+ </button>
364
+ <div class="dropdown-menu p-3 c-page-menu" :aria-labelledby="`goToPageDropdown-${pageItem}`">
365
+ <div class="input-group">
366
+ <input :id="`goToPageInput-${pageItem}`" type="number" class="form-control" placeholder="頁碼"
367
+ min="1" :max="pageModel.totalPage" v-model="pageGoToInputValue"
368
+ @keydown.enter="doGoToPage"/>
369
+ <button type="button" class="btn btn-sm btn-secondary" @click="doGoToPage">前往</button>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </template>
374
+ </li>
375
+ <li class="page-item" v-if="pageModel.hasNextPage()">
376
+ <button class="page-link c-page-action"
377
+ title="後一頁" aria-label="後一頁"
378
+ @click="pageActionMap.next">
379
+ <i :class="nextIcon"></i>
380
+ </button>
381
+ </li>
382
+ </ul>
383
+ </nav>
384
+ </div>
385
+ </div>
386
+ </div>
387
+ </template>
388
+
389
+ <style>
390
+ /* 非 scoped 樣式:定義 CSS 變數 */
391
+ .content-table {
392
+ /* 定義 CSS 變數預設值 */
393
+ --c-pagination-bg-color: #fff;
394
+ --c-pagination-text-color: #2e2e2e;
395
+ --c-pagination-border-color: #E4B445;
396
+ --c-pagination-hover-bg-color: #E4B445;
397
+ --c-pagination-hover-text-color: #fff;
398
+ --c-pagination-hover-border-color: #d4a33c;
399
+ --c-pagination-active-bg-color: #E4B445;
400
+ --c-pagination-active-text-color: #fff;
401
+ --c-pagination-active-border-color: #E4B445;
402
+ --c-scrollbar-thumb-color: #E4B445;
403
+ --c-scrollbar-track-color: #f1f1f1;
404
+ --c-scrollbar-thumb-hover-color: #d4a33c;
405
+ --c-scrollbar-height: 8px;
406
+ }
407
+
408
+ .content-table-body {
409
+ /* 自訂 scrollbar 樣式 */
410
+ scrollbar-width: thin; /* Firefox */
411
+ scrollbar-color: var(--c-scrollbar-thumb-color) var(--c-scrollbar-track-color); /* Firefox: thumb 和 track 顏色 */
412
+ }
413
+
414
+ /* WebKit 瀏覽器 (Chrome, Safari, Edge) */
415
+ .content-table-body::-webkit-scrollbar {
416
+ height: var(--c-scrollbar-height); /* scrollbar 高度 */
417
+ }
418
+
419
+ .content-table-body::-webkit-scrollbar-track {
420
+ background: var(--c-scrollbar-track-color); /* track 背景色 */
421
+ border-radius: 4px;
422
+ }
423
+
424
+ .content-table-body::-webkit-scrollbar-thumb {
425
+ background: var(--c-scrollbar-thumb-color); /* thumb 顏色 */
426
+ border-radius: 4px;
427
+ transition: background 0.3s;
428
+ }
429
+
430
+ .content-table-body::-webkit-scrollbar-thumb:hover {
431
+ background: var(--c-scrollbar-thumb-hover-color); /* hover 時的顏色 */
432
+ }
433
+
434
+ </style>
435
+
436
+ <style scoped>
437
+ .content-table {
438
+ display: flex;
439
+ flex-direction: column;
440
+ justify-content: center;
441
+ align-items: center;
442
+ }
443
+
444
+ .content-table table {
445
+ table-layout: auto;
446
+ }
447
+
448
+ .content-table-body {
449
+ width: 100%;
450
+ overflow-x: auto;
451
+ }
452
+
453
+ .content-table table th,
454
+ .content-table table td {
455
+ width: auto;
456
+ }
457
+
458
+ .content-table table td {
459
+ vertical-align: middle;
460
+ text-align: left;
461
+ }
462
+
463
+ .content-table-footer {
464
+ border-top: 1px solid #dee2e6;
465
+ }
466
+
467
+ .content-table .pagination .page-link {
468
+ min-width: 36px;
469
+ text-align: center;
470
+ display: inline-block;
471
+ color: var(--c-pagination-text-color, #2e2e2e);
472
+ background-color: var(--c-pagination-bg-color, #fff);
473
+ border: 1px solid var(--c-pagination-border-color, #E4B445);
474
+ margin: 0 2px;
475
+ border-radius: 4px;
476
+ transition: background-color .3s, color .3s;
477
+ }
478
+ .content-table .pagination .page-link:hover,
479
+ .content-table .pagination .page-link:focus {
480
+ background-color: var(--c-pagination-hover-bg-color, #E4B445);
481
+ color: var(--c-pagination-hover-text-color, #fff);
482
+ border-color: var(--c-pagination-hover-border-color, #d4a33c);
483
+ }
484
+ .content-table .pagination .active .page-link {
485
+ background-color: var(--c-pagination-active-bg-color, #E4B445);
486
+ color: var(--c-pagination-active-text-color, #fff);
487
+ border-color: var(--c-pagination-active-border-color, #E4B445);
488
+ }
489
+
490
+ .content-table .pagination .page-link.c-page-action {
491
+ padding-left: 0.2rem;
492
+ padding-right: 0.2rem;
493
+ }
494
+
495
+ .dropdown-menu.c-page-menu {
496
+ min-width: 200px;
497
+ }
498
+
499
+ .c-sortable {
500
+ cursor: pointer;
501
+ user-select: none;
502
+ transition: background-color 0.2s;
503
+ }
504
+
505
+ .c-sortable:hover {
506
+ background-color: rgba(0, 123, 255, 0.1);
507
+ }
508
+
509
+ .c-sort-icon {
510
+ font-size: 16px;
511
+ color: #6c757d;
512
+ transition: color 0.2s;
513
+ }
514
+
515
+ .c-sortable:hover .c-sort-icon {
516
+ color: #007bff;
517
+ }
518
+
519
+ .c-sortable .c-arrow-up,
520
+ .c-sortable .c-arrow-down {
521
+ color: #007bff;
522
+ }
523
+
524
+ </style>