ch3chi-commons-vue 1.2.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.
- package/package.json +2 -1
- package/src/api/ApiService.ts +869 -0
- package/src/auth/AuthorizationService.ts +138 -0
- package/src/auth/PermissionDescriptor.ts +99 -0
- package/src/auth/keys.ts +5 -0
- package/src/components/CAlert.vue +188 -0
- package/src/components/CAlertDefine.ts +20 -0
- package/src/components/CBSToast.vue +119 -0
- package/src/components/CGlobalSpinner.vue +84 -0
- package/src/components/CImage.vue +67 -0
- package/src/components/CRowCheckBox.vue +75 -0
- package/src/components/CRowTextInput.vue +27 -0
- package/src/components/CTable.vue +524 -0
- package/src/components/CTableDefine.ts +566 -0
- package/src/components/CTableTD.vue +28 -0
- package/src/components/HasPermission.vue +28 -0
- package/src/components/form/CChangePasswordFormField.vue +146 -0
- package/src/components/form/CCheckBoxFormField.vue +91 -0
- package/src/components/form/CCheckBoxPlatFormField.vue +94 -0
- package/src/components/form/CDateFormField.vue +149 -0
- package/src/components/form/CDateQueryField.vue +111 -0
- package/src/components/form/CDateRangeFormField.vue +138 -0
- package/src/components/form/CFilePickerFormField.vue +471 -0
- package/src/components/form/CRadioFormField.vue +62 -0
- package/src/components/form/CRadioPlatFormField.vue +67 -0
- package/src/components/form/CSelectFormField.vue +175 -0
- package/src/components/form/CTextAreaFormField.vue +84 -0
- package/src/components/form/CTextInputFormField.vue +99 -0
- package/src/components/form/CTinyMCEEditorFormField.vue +99 -0
- package/src/components/form/SCTextInputFormField.vue +129 -0
- package/src/composables/useCheckBoxFormField.ts +126 -0
- package/src/composables/useRadioFormField.ts +106 -0
- package/src/directive/CBootstrapDirective.ts +83 -0
- package/src/directive/CDateFormatterDirective.ts +37 -0
- package/src/directive/CFTurnstileDirective.ts +46 -0
- package/src/directive/CFormDirective.ts +57 -0
- package/src/directive/PermissionDirective.ts +102 -0
- package/src/env.d.ts +19 -0
- package/src/index.ts +83 -0
- package/src/model/BSFieldStyleConfig.ts +349 -0
- package/src/model/BaseDictionary.ts +86 -0
- package/src/model/BaseFormDataModel.ts +623 -0
- package/src/model/BaseListViewModel.ts +392 -0
- package/src/model/CBSModalViewModel.ts +91 -0
- package/src/model/CFileDataModel.ts +181 -0
- package/src/model/CImageViewModel.ts +34 -0
- package/src/model/CMenuItem.ts +199 -0
- package/src/model/EmailReceiverDataModel.ts +149 -0
- package/src/model/EmptyDataModel.ts +25 -0
- package/src/model/FormOptions.ts +112 -0
- package/src/model/LoginDataModel.ts +51 -0
- package/src/model/PasswordDataModel.ts +70 -0
- package/src/model/QueryParameter.ts +310 -0
- package/src/model/SessionUser.ts +110 -0
- package/src/model/ShowMessageDataModel.ts +69 -0
- package/src/model/TokenUser.ts +157 -0
- package/src/stores/FormDataStore.ts +73 -0
- package/src/stores/ViewStore.ts +701 -0
- package/src/stores/VueSessionStoreInstaller.ts +22 -0
- package/src/types/turnstile.d.ts +8 -0
- package/src/utils/CToolUtils.ts +133 -0
- package/dist/api/ApiService.d.ts +0 -233
- package/dist/auth/AuthorizationService.d.ts +0 -56
- package/dist/auth/PermissionDescriptor.d.ts +0 -37
- package/dist/components/CAlert.vue.d.ts +0 -17
- package/dist/components/CAlertDefine.d.ts +0 -14
- package/dist/components/CBSToast.vue.d.ts +0 -6
- package/dist/components/CGlobalSpinner.vue.d.ts +0 -13
- package/dist/components/CRowCheckBox.vue.d.ts +0 -14
- package/dist/components/CRowTextInput.vue.d.ts +0 -10
- package/dist/components/CTable.vue.d.ts +0 -24
- package/dist/components/CTableDefine.d.ts +0 -201
- package/dist/components/CTableTD.vue.d.ts +0 -7
- package/dist/components/form/CChangePasswordFormField.vue.d.ts +0 -14
- package/dist/components/form/CCheckBoxFormField.vue.d.ts +0 -30
- package/dist/components/form/CDateFormField.vue.d.ts +0 -17
- package/dist/components/form/CDateQueryField.vue.d.ts +0 -16
- package/dist/components/form/CDateRangeFormField.vue.d.ts +0 -17
- package/dist/components/form/CFilePickerFormField.vue.d.ts +0 -28
- package/dist/components/form/CRadioFormField.vue.d.ts +0 -30
- package/dist/components/form/CSelectFormField.vue.d.ts +0 -18
- package/dist/components/form/CTextAreaFormField.vue.d.ts +0 -16
- package/dist/components/form/CTextInputFormField.vue.d.ts +0 -22
- package/dist/directive/CBootstrapDirective.d.ts +0 -17
- package/dist/directive/CDateFormatterDirective.d.ts +0 -10
- package/dist/directive/CFTurnstileDirective.d.ts +0 -15
- package/dist/directive/CFormDirective.d.ts +0 -9
- package/dist/directive/PermissionDirective.d.ts +0 -15
- package/dist/index.cjs.js +0 -19103
- package/dist/index.d.ts +0 -45
- package/dist/index.es.js +0 -19086
- package/dist/model/BSFieldStyleConfig.d.ts +0 -121
- package/dist/model/BaseDictionary.d.ts +0 -34
- package/dist/model/BaseFormDataModel.d.ts +0 -199
- package/dist/model/BaseListViewModel.d.ts +0 -165
- package/dist/model/CBSModalViewModel.d.ts +0 -44
- package/dist/model/CFileDataModel.d.ts +0 -74
- package/dist/model/CImageViewModel.d.ts +0 -8
- package/dist/model/CMenuItem.d.ts +0 -86
- package/dist/model/EmailReceiverDataModel.d.ts +0 -57
- package/dist/model/EmptyDataModel.d.ts +0 -7
- package/dist/model/FormOptions.d.ts +0 -60
- package/dist/model/LoginDataModel.d.ts +0 -12
- package/dist/model/PasswordDataModel.d.ts +0 -15
- package/dist/model/QueryParameter.d.ts +0 -92
- package/dist/model/SessionUser.d.ts +0 -45
- package/dist/model/ShowMessageDataModel.d.ts +0 -44
- package/dist/model/TokenUser.d.ts +0 -50
- package/dist/stores/FormDataStore.d.ts +0 -31
- package/dist/stores/ViewStore.d.ts +0 -349
- package/dist/style.css +0 -223
- package/dist/utils/CToolUtils.d.ts +0 -53
|
@@ -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>
|