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.
Files changed (112) 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
  62. package/dist/api/ApiService.d.ts +0 -233
  63. package/dist/auth/AuthorizationService.d.ts +0 -56
  64. package/dist/auth/PermissionDescriptor.d.ts +0 -37
  65. package/dist/components/CAlert.vue.d.ts +0 -17
  66. package/dist/components/CAlertDefine.d.ts +0 -14
  67. package/dist/components/CBSToast.vue.d.ts +0 -6
  68. package/dist/components/CGlobalSpinner.vue.d.ts +0 -13
  69. package/dist/components/CRowCheckBox.vue.d.ts +0 -14
  70. package/dist/components/CRowTextInput.vue.d.ts +0 -10
  71. package/dist/components/CTable.vue.d.ts +0 -24
  72. package/dist/components/CTableDefine.d.ts +0 -201
  73. package/dist/components/CTableTD.vue.d.ts +0 -7
  74. package/dist/components/form/CChangePasswordFormField.vue.d.ts +0 -14
  75. package/dist/components/form/CCheckBoxFormField.vue.d.ts +0 -30
  76. package/dist/components/form/CDateFormField.vue.d.ts +0 -17
  77. package/dist/components/form/CDateQueryField.vue.d.ts +0 -16
  78. package/dist/components/form/CDateRangeFormField.vue.d.ts +0 -17
  79. package/dist/components/form/CFilePickerFormField.vue.d.ts +0 -28
  80. package/dist/components/form/CRadioFormField.vue.d.ts +0 -30
  81. package/dist/components/form/CSelectFormField.vue.d.ts +0 -18
  82. package/dist/components/form/CTextAreaFormField.vue.d.ts +0 -16
  83. package/dist/components/form/CTextInputFormField.vue.d.ts +0 -22
  84. package/dist/directive/CBootstrapDirective.d.ts +0 -17
  85. package/dist/directive/CDateFormatterDirective.d.ts +0 -10
  86. package/dist/directive/CFTurnstileDirective.d.ts +0 -15
  87. package/dist/directive/CFormDirective.d.ts +0 -9
  88. package/dist/directive/PermissionDirective.d.ts +0 -15
  89. package/dist/index.cjs.js +0 -19103
  90. package/dist/index.d.ts +0 -45
  91. package/dist/index.es.js +0 -19086
  92. package/dist/model/BSFieldStyleConfig.d.ts +0 -121
  93. package/dist/model/BaseDictionary.d.ts +0 -34
  94. package/dist/model/BaseFormDataModel.d.ts +0 -199
  95. package/dist/model/BaseListViewModel.d.ts +0 -165
  96. package/dist/model/CBSModalViewModel.d.ts +0 -44
  97. package/dist/model/CFileDataModel.d.ts +0 -74
  98. package/dist/model/CImageViewModel.d.ts +0 -8
  99. package/dist/model/CMenuItem.d.ts +0 -86
  100. package/dist/model/EmailReceiverDataModel.d.ts +0 -57
  101. package/dist/model/EmptyDataModel.d.ts +0 -7
  102. package/dist/model/FormOptions.d.ts +0 -60
  103. package/dist/model/LoginDataModel.d.ts +0 -12
  104. package/dist/model/PasswordDataModel.d.ts +0 -15
  105. package/dist/model/QueryParameter.d.ts +0 -92
  106. package/dist/model/SessionUser.d.ts +0 -45
  107. package/dist/model/ShowMessageDataModel.d.ts +0 -44
  108. package/dist/model/TokenUser.d.ts +0 -50
  109. package/dist/stores/FormDataStore.d.ts +0 -31
  110. package/dist/stores/ViewStore.d.ts +0 -349
  111. package/dist/style.css +0 -223
  112. package/dist/utils/CToolUtils.d.ts +0 -53
@@ -0,0 +1,138 @@
1
+ <script setup lang="ts">
2
+ import {inject, nextTick, onMounted, Ref, ref, watch, computed} from "vue";
3
+ import flatpickr from "flatpickr";
4
+ import "flatpickr/dist/flatpickr.min.css";
5
+ import {Mandarin} from "flatpickr/dist/l10n/zh.js";
6
+ import {v4 as uuidv4} from 'uuid';
7
+ import {BaseFormDataModel, hasFormFieldError} from "../../model/BaseFormDataModel";
8
+ import {EmptyDataModel} from "../../model/EmptyDataModel";
9
+ import * as _ from 'lodash';
10
+ import {BSFieldStyleConfig, IBSFieldStyleConfig} from "../../model/BSFieldStyleConfig";
11
+
12
+ // ----- props -----
13
+ const props = withDefaults(defineProps<{
14
+ id?: string; // 欄位 ID
15
+ label?: string; // 欄位標籤
16
+ name: string; // 欄位名稱
17
+ required?: boolean; // 是否為必填
18
+ requiredReactive?: boolean; // 是否為必填 (響應式)
19
+ startDateFieldName?: string; // 開始日期欄位名稱
20
+ endDateFieldName?: string; // 結束日期欄位名稱
21
+ styleConfig?: IBSFieldStyleConfig; // 樣式設定
22
+ }>(), {
23
+ requiredReactive: undefined,
24
+ label: "選擇日期區間",
25
+ startDateFieldName: "startDate",
26
+ endDateFieldName: "endDate"
27
+ });
28
+
29
+ // ----- view model -----
30
+ const viewModel = inject('c-formViewModel', new EmptyDataModel()) as BaseFormDataModel;
31
+ if(!viewModel) {
32
+ console.error('CDateRangeFormField: viewModel not found, please provide "c-formViewModel" in parent component');
33
+ }
34
+
35
+ // BSFieldStyleConfig
36
+ const bStyleConfig = BSFieldStyleConfig.mix({}, props.styleConfig);
37
+
38
+ // ----- form field -----
39
+ const formFieldMap = viewModel.formFieldMap;
40
+ const startDateFieldModel = _.get(formFieldMap, props.startDateFieldName);
41
+ const startDateFieldRef = startDateFieldModel?.value as Ref<Date>;
42
+ const endDateFieldModel = _.get(formFieldMap, props.endDateFieldName);
43
+ const endDateFieldRef = endDateFieldModel?.value as Ref<Date>;
44
+ if (!startDateFieldRef || !endDateFieldRef) {
45
+ console.error('check fieldMap for CDateRangeFormField:', formFieldMap);
46
+ console.error('CDateRangeFormField: field model not found for start or end date:',
47
+ props.startDateFieldName, props.endDateFieldName);
48
+ }
49
+
50
+ // ----- 控制項 attribute -----
51
+ const triggerBtnId = props.id || `date-range-btn-${uuidv4()}`;
52
+ const dateBtn = ref<HTMLButtonElement | null>(null);
53
+ const flatpickrInstance = ref<any>(null);
54
+ const isRequired = computed(() => {
55
+ if (typeof props.requiredReactive === 'boolean') {
56
+ return props.requiredReactive;
57
+ }
58
+ return props.required || viewModel.fieldIsRequired(props.startDateFieldName) || viewModel.fieldIsRequired(props.endDateFieldName);
59
+ });
60
+
61
+ // ----- event handler -----
62
+ function toggleFlatpickr() {
63
+ if(flatpickrInstance.value?.isOpen) {
64
+ flatpickrInstance.value?.close();
65
+ } else {
66
+ flatpickrInstance.value?.open();
67
+ }
68
+ }
69
+
70
+ // ----- watch -----
71
+ watch(startDateFieldRef, (newDate) => {
72
+ flatpickrInstance.value?.setDate([newDate, endDateFieldRef.value], false); // 第二個參數 false 表示不觸發 onChange 事件
73
+ });
74
+ watch(endDateFieldRef, (newDate) => {
75
+ flatpickrInstance.value?.setDate([startDateFieldRef.value, newDate], false); // 第二個參數 false 表示不觸發 onChange 事件
76
+ });
77
+
78
+ // ----- LifeCycle -----
79
+ onMounted(() => {
80
+ nextTick(() => {
81
+ flatpickrInstance.value = flatpickr(`#${triggerBtnId}`, {
82
+ mode: "range", // 啟用日期區間選擇
83
+ dateFormat: "Y-m-d", // 日期格式
84
+ locale: Mandarin, // 設定語系為中文
85
+ onChange: (selectedDates) => {
86
+ if (selectedDates.length === 2) {
87
+ // 更新開始和結束日期
88
+ const startDate = selectedDates[0];
89
+ // 設定開始日期的時間為 00:00:00
90
+ startDate.setHours(0, 0, 0, 0);
91
+ startDateFieldRef.value = startDate;
92
+ // 設定結束日期的時間為 23:59:59
93
+ const endDate = selectedDates[1];
94
+ endDate.setHours(23, 59, 59, 999);
95
+ endDateFieldRef.value = endDate;
96
+ }
97
+ }
98
+ });
99
+ })
100
+ });
101
+
102
+ </script>
103
+
104
+ <template>
105
+ <div :class="bStyleConfig.containerClass">
106
+ <label v-if="props.label && !bStyleConfig.hideLabel" :for="triggerBtnId" :class="bStyleConfig.labelClass">
107
+ {{ props.label }}
108
+ <span v-if="isRequired" :class="bStyleConfig.requiredLabelClass">{{bStyleConfig.requiredLabelText}}</span>
109
+ </label>
110
+ <div :class="bStyleConfig.wrapperClass">
111
+ <button type="button" class="btn btn-outline-secondary"
112
+ title="點擊選擇日期區間" v-tooltip
113
+ ref="dateBtn"
114
+ :id="triggerBtnId" @click.prevent.stop>
115
+ <font-icon icon="calendar-days"/>
116
+ </button>
117
+ <input type="text" :class="bStyleConfig.inputClass" placeholder="開始日期" readonly
118
+ v-date-formatter="startDateFieldRef"
119
+ v-form-invalid="startDateFieldModel"
120
+ @click.prevent.stop="toggleFlatpickr">
121
+ <span class="input-group-text">~</span>
122
+ <input type="text" :class="bStyleConfig.inputClass" placeholder="結束日期" readonly
123
+ v-date-formatter="endDateFieldRef"
124
+ v-form-invalid="endDateFieldModel"
125
+ @click.prevent.stop="toggleFlatpickr">
126
+ </div>
127
+ <div v-if="hasFormFieldError(startDateFieldModel)" :class="bStyleConfig.errorClass">
128
+ {{ startDateFieldModel.errorMessage }}
129
+ </div>
130
+ <div v-if="hasFormFieldError(endDateFieldModel)" :class="bStyleConfig.errorClass">
131
+ {{ endDateFieldModel.errorMessage }}
132
+ </div>
133
+ </div>
134
+ </template>
135
+
136
+ <style scoped>
137
+
138
+ </style>
@@ -0,0 +1,471 @@
1
+ <script setup lang="ts">
2
+ import {computed, isReactive, onMounted, reactive, ref, toRaw, watch} from "vue";
3
+ import {useViewStore} from "../../stores/ViewStore";
4
+ import {CFileDataModel, FileUploadProgress} from "../../model/CFileDataModel";
5
+ import {v4 as uuidv4} from 'uuid';
6
+ import _ from 'lodash';
7
+ import {checkHasSameFile} from '../../utils/CToolUtils';
8
+ import {ApiService} from "../../api/ApiService";
9
+ import {AxiosProgressEvent} from "axios";
10
+ import {FontAwesomeIcon as FontIcon} from "@fortawesome/vue-fontawesome";
11
+ import {BSFieldStyleConfig, IBSFieldStyleConfig} from "../../model/BSFieldStyleConfig";
12
+
13
+ // ----- Properties -----
14
+ const props = withDefaults(defineProps<{
15
+ id?: string;
16
+ label?: string;
17
+ name?: string; // 對應到 dataModel 的欄位名稱
18
+ dataModel?: any; // 用於雙向綁定的資料模型
19
+ validationDataKey?: string; // 用於驗證的資料鍵值,預設為 name
20
+ placeholder?: string;
21
+ tip?: string; // 提示訊息
22
+ accept?: string;
23
+ required?: boolean;
24
+ multiple?: boolean; // 是否支援多檔案上傳
25
+ fileCountLimit?: number; // 檔案數量限制,只會在 multiple 為 true 時生效
26
+ fileSizeLimit?: number; // 檔案大小限制,單位為 bytes
27
+ styleConfig?: IBSFieldStyleConfig; // BS 欄位樣式設定
28
+ }>(), {
29
+ label: '',
30
+ placeholder: '請選擇檔案'
31
+ });
32
+
33
+ // BSFieldStyleConfig
34
+ const bStyleConfig = BSFieldStyleConfig.mix({
35
+ fileClass: 'form-control cursor-pointer c-single-file'
36
+ }, props.styleConfig);
37
+
38
+ // ----- attribute -----
39
+ // componentId 用於唯一標識這個組件
40
+ const componentId = props.id || `file-picker-${uuidv4()}`;
41
+ // 可接受的檔案類型
42
+ const acceptedFileTypes = bStyleConfig.fileAccept || props.accept || '*';
43
+ // isRequired 用於判斷是否為必填欄位
44
+ const isRequired = props.required || false;
45
+ const boxClassName = props.multiple ? 'c-multiple-file-box' : 'input-group c-single-file-box'; // 根據是否多檔案上傳來決定 class 名稱
46
+ // file 物件
47
+ const selectedFile = ref<File | null>(null); // 用於單個檔案
48
+ const showSingleFilePicker = ref(props.multiple === false);
49
+ const selectedFileList = reactive<File[]>([]); // 用於多檔案上傳
50
+ // 上傳進度
51
+ const uploadProgressMap = reactive<Record<string, FileUploadProgress>>({});
52
+ // 已上傳檔案列表
53
+ const uploadedFileList = reactive<CFileDataModel[]>([]);
54
+
55
+ // maxFileSize 用於限制檔案大小,從 props or env 或其他配置中獲取,單位為 bytes
56
+ const maxFileSize = props.fileSizeLimit || import.meta.env.VITE_MAX_FILE_SIZE || 2 * 1024 * 1024; // 預設為 2MB
57
+ // 偵測是否已經超過最大檔案個數限制
58
+ const isOverFileCountLimit = computed(() => {
59
+ if(!props.multiple || !props.fileCountLimit) {
60
+ return false; // 如果不是多檔案上傳或沒有設定檔案數量限制,則不需要檢查
61
+ }
62
+ return selectedFileList.length >= props.fileCountLimit;
63
+ });
64
+
65
+ // ----- view model -----
66
+ // 用來驗證是否有錯誤訊息
67
+ const formFieldMap = props.dataModel?.formFieldMap;
68
+ const validationKey = props.validationDataKey || props.name || '';
69
+ const fieldModel = formFieldMap[validationKey];
70
+
71
+ // 是否自動上傳檔案
72
+ const enableAutoUpload = true;
73
+
74
+ // 引入 viewStore 用於顯示錯誤訊息
75
+ const viewStore = useViewStore();
76
+
77
+ // input type="file" element ref
78
+ const fileInputRef = ref<HTMLInputElement | null>(null);
79
+ const textInputRef = ref<HTMLInputElement | null>(null);
80
+
81
+ // ----- Methods -----
82
+
83
+ // 顯示選擇的檔案名稱到文字輸入框
84
+ const displayFileName = (): void => {
85
+ const fileInputElt = fileInputRef.value;
86
+ const textInputElt = textInputRef.value;
87
+ if (!fileInputElt || !textInputElt) {
88
+ return;
89
+ }
90
+ // 根據單選或多選來顯示檔案名稱
91
+ // 單選情況下,把選擇的檔案名稱顯示在文字輸入框中
92
+ if (!props.multiple) {
93
+ if (selectedFile.value) {
94
+ textInputElt.value = selectedFile.value.name; // 顯示檔案名稱
95
+ } else {
96
+ textInputElt.value = ''; // 清除檔案名稱
97
+ }
98
+ return;
99
+ }
100
+ // 多選情況下,顯示所有選擇的檔案名稱到顯示區中,textInputElt 不顯示檔案名稱
101
+ textInputElt.value = '';
102
+ };
103
+
104
+ // ----- event handler -----
105
+ // 顯示檔案選擇器
106
+ const doShowFilePicker = (): void => {
107
+ const fileInputElt = fileInputRef.value;
108
+ if (fileInputElt) {
109
+ fileInputElt.click(); // 模擬點擊檔案選擇按鈕
110
+ }
111
+ };
112
+ // 處理檔案選擇事件
113
+ const onFileSelected = (event: Event): void => {
114
+ const fileInput = event.target as HTMLInputElement;
115
+ if(!fileInput || !fileInput.files || fileInput.files.length === 0) {
116
+ selectedFile.value = null; // 清除選擇的檔案
117
+ displayFileName(); // 更新顯示的檔案名稱
118
+ return;
119
+ }
120
+
121
+ // 檢查是否支援多檔案上傳
122
+ // 當使用多檔案上傳
123
+ if (props.multiple) {
124
+ // 偵測到選擇的檔案數量是否超過限制,以及 selectedFileList 的長度是否超過限制
125
+ if (props.fileCountLimit && (fileInput.files.length + selectedFileList.length + uploadedFileList.length) > props.fileCountLimit) {
126
+ viewStore.showModalError({
127
+ content: `最多只能選擇 ${props.fileCountLimit} 個檔案`
128
+ });
129
+ return;
130
+ }
131
+
132
+ // 如果支援多檔案上傳,則將所有選擇的檔案儲存到 selectedFileList
133
+ // 這邊使用累加的方式來儲存選擇的檔案
134
+ for (const file of Array.from(fileInput.files)) {
135
+ // 檢查檔案是否已經在 selectedFileList 中
136
+ if (checkHasSameFile(selectedFileList, file)) {
137
+ continue;
138
+ }
139
+ // 檢查檔案大小是否超過限制
140
+ if (file.size > maxFileSize) {
141
+ viewStore.showModalError({
142
+ content: `檔案 ${file.name} 超過最大允許大小 ${maxFileSize / (1024 * 1024)} MB`
143
+ });
144
+ continue;
145
+ }
146
+ selectedFileList.push(file); // 儲存選擇的檔案
147
+ uploadProgressMap[file.name] = new FileUploadProgress();
148
+ }
149
+ // 清除單個檔案選擇
150
+ selectedFile.value = null;
151
+ // 更新顯示的檔案名稱
152
+ displayFileName();
153
+ // 把 fileInputRef 的值清空,這樣才能在下次選擇相同檔案時觸發 change 事件
154
+ fileInput.value = '';
155
+ // 如果啟用自動上傳,則自動上傳所有選擇的檔案
156
+ if(enableAutoUpload) {
157
+ selectedFileList.forEach(file => {
158
+ doUploadFile(file);
159
+ });
160
+ }
161
+ return;
162
+ }
163
+
164
+ // 單檔案上傳情況下,檢查是否已經有選擇的檔案或已經上傳的檔案
165
+ if(enableAutoUpload) {
166
+ const hasFileAlready = selectedFile.value !== null || uploadedFileList.length > 0;
167
+ if(hasFileAlready) {
168
+ viewStore.showModalError({
169
+ content: `只能選擇一個檔案`
170
+ });
171
+ doReset();
172
+ return;
173
+ }
174
+ }
175
+ // 單檔案上傳情況下,僅儲存第一個選擇的檔案
176
+ const firstFile = fileInput.files[0];
177
+ if (firstFile.size > maxFileSize) {
178
+ viewStore.showModalError({
179
+ content: `最大允許檔案大小為 ${maxFileSize / (1024 * 1024)} MB`
180
+ })
181
+ doReset()
182
+ return;
183
+ }
184
+ // 儲存選擇的檔案
185
+ selectedFile.value = firstFile;
186
+ displayFileName();
187
+ // 把 fileInputRef 的值清空,這樣才能在下次選擇相同檔案時觸發 change 事件
188
+ fileInput.value = '';
189
+ if(enableAutoUpload) {
190
+ uploadProgressMap[selectedFile.value.name] = new FileUploadProgress();
191
+ doUploadFile(selectedFile.value);
192
+ showSingleFilePicker.value = false;
193
+ }
194
+ return;
195
+ };
196
+ // reset 檔案選擇
197
+ const doReset = (): void => {
198
+ const fileInputElt = fileInputRef.value;
199
+ if (!fileInputElt) {
200
+ return;
201
+ }
202
+ fileInputElt.value = ''; // 清除檔案選擇
203
+ selectedFile.value = null; // 清除選擇的檔案
204
+ selectedFileList.splice(0, selectedFileList.length); // 清空已選擇的檔案列表
205
+ // 清除上傳進度
206
+ Object.keys(uploadProgressMap).forEach(key => {
207
+ _.unset(uploadProgressMap, key);
208
+ });
209
+ uploadedFileList.splice(0, uploadedFileList.length); // 清空已上傳的檔案列表
210
+ showSingleFilePicker.value = props.multiple === false;
211
+ displayFileName(); // 更新顯示的檔案名稱
212
+ };
213
+
214
+ // 上傳檔案
215
+ async function doUploadFile(file: File) {
216
+ const formData = new FormData();
217
+ formData.append('file', file);
218
+
219
+ // 初始化上傳進度
220
+ const uploadProgress = uploadProgressMap[file.name];
221
+ uploadProgress.start();
222
+ // 處理上傳進度事件
223
+ const handleUploadProgress = (event: AxiosProgressEvent) => {
224
+ if (event.lengthComputable && event.total) {
225
+ const percentCompleted = Math.round((event.loaded * 100) / event.total);
226
+ uploadProgress.progress = percentCompleted;
227
+ } else {
228
+ uploadProgress.progress = 0;
229
+ }
230
+ };
231
+ // 呼叫上傳 API
232
+ const resp = await ApiService.call({
233
+ endpointKey: 'Resources-Create',
234
+ postBody : formData,
235
+ onUploadProgress: handleUploadProgress
236
+ });
237
+ if(!resp.isOk()) {
238
+ console.error(`upload file fail`, resp);
239
+ uploadProgress.fail('上傳失敗');
240
+ return;
241
+ }
242
+
243
+ // 上傳成功,將進度設為完成
244
+ uploadProgressMap[file.name].complete();
245
+ // 模擬上傳完成,將檔案加入已上傳列表
246
+ const fileDataModel = new CFileDataModel({
247
+ ...resp.data,
248
+ name: file.name,
249
+ size: file.size,
250
+ fileObj: file
251
+ });
252
+ uploadedFileList.push(fileDataModel);
253
+ // 移除已上傳的檔案
254
+ if(props.multiple) {
255
+ _.remove<File[]>(selectedFileList, (f: File | null) => _.isNil(f) || f.name === file.name);
256
+ }else {
257
+ selectedFile.value = null;
258
+ displayFileName();
259
+ }
260
+ }
261
+
262
+ // 檢查是否可以移除已選擇的檔案
263
+ function disableRemoveSelectedFile(fileItem: File): boolean {
264
+ const uploadProgress = uploadProgressMap[fileItem.name];
265
+ return uploadProgress && !uploadProgress.readyToUpload();
266
+ }
267
+
268
+ // 移除已選擇的檔案
269
+ function removeSelectedFile(fileName: string) {
270
+ _.remove<File[]>(selectedFileList, (f: File | null) => _.isNil(f) || f.name === fileName);
271
+ delete uploadProgressMap[fileName];
272
+ }
273
+ // 移除已上傳的檔案
274
+ const onlyRemoveUploadFile = (fileItem: CFileDataModel) => {
275
+ _.remove<CFileDataModel[]>(uploadedFileList, (f: CFileDataModel) => f === fileItem);
276
+ }
277
+ async function removeUploadedFile(fileItem: CFileDataModel) {
278
+ // 如果是關聯檔案,則不呼叫 API 刪除,而是直接從列表中移除直到使用者點擊儲存才會真正刪除
279
+ // if(fileItem.isAssociated === true) {
280
+ // onlyRemoveUploadFile(fileItem);
281
+ // return;
282
+ // }
283
+
284
+ // 如果已經有 uid 則呼叫 API
285
+ if(fileItem.uid) {
286
+ const resp = await ApiService.call({
287
+ endpointKey: 'Resources-Delete',
288
+ pathParam: { uid: fileItem.uid },
289
+ postBody: {}
290
+ });
291
+ if(!resp.isOk()) {
292
+ console.error(`delete file fail`, resp);
293
+ return;
294
+ }
295
+ }
296
+ onlyRemoveUploadFile(fileItem);
297
+ // 如果是單檔案上傳,則顯示檔案選擇器
298
+ if(!props.multiple) {
299
+ showSingleFilePicker.value = true;
300
+ }
301
+ }
302
+
303
+ // ----- 資料於不同元件同步 -----
304
+ // 監聽 uploadedFileList 的變化,並同步到 dataModel
305
+ watch(uploadedFileList, () => {
306
+ syncToDataModel();
307
+ }, { deep: true });
308
+ // 同步到 dataModel
309
+ function syncToDataModel() {
310
+ if(!props.name || !props.dataModel) {
311
+ return;
312
+ }
313
+ // 根據是否多檔案上傳來決定同步的值
314
+ if(props.multiple) {
315
+ props.dataModel[props.name] = uploadedFileList.map(item => {
316
+ if(isReactive(item)) {
317
+ return toRaw(item);
318
+ }
319
+ return item;
320
+ });
321
+ _.invoke(props.dataModel, 'applyPropertiesToFormField');
322
+ _.invoke(props.dataModel, 'validateField', props.name);
323
+ return;
324
+ }
325
+
326
+ // 單檔案上傳情況
327
+ const fileItem = uploadedFileList.map(item => {
328
+ if(isReactive(item)) {
329
+ return toRaw(item);
330
+ }
331
+ return item;
332
+ }).pop();
333
+ _.set(props.dataModel, props.name, fileItem || null);
334
+ _.invoke(props.dataModel, 'applyPropertiesToFormField');
335
+ _.invoke(props.dataModel, 'validateField', props.name);
336
+ }
337
+
338
+ // 讓父元件可以呼叫,強制同步資料
339
+ function addSavedFiles(files: CFileDataModel[]) {
340
+ uploadedFileList.push(...files);
341
+ if(!props.multiple) {
342
+ // 如果是單檔案上傳,則不顯示檔案選擇器
343
+ showSingleFilePicker.value = false;
344
+ }
345
+ }
346
+
347
+ // ----- LifeCycle -----
348
+ onMounted((): void => {
349
+
350
+ });
351
+
352
+ // ----- expose -----
353
+ defineExpose({
354
+ addSavedFiles
355
+ });
356
+
357
+ </script>
358
+
359
+ <template>
360
+ <div :id="componentId" :class="bStyleConfig.containerClass">
361
+ <label v-if="props.label && !bStyleConfig.hideLabel" :class="bStyleConfig.labelClass">
362
+ {{ props.label }}
363
+ <span v-if="isRequired" :class="bStyleConfig.requiredLabelClass">{{bStyleConfig.requiredLabelText}}</span>
364
+ <span v-if="props.tip" class="ms-2" :title="props.tip" v-tooltip>
365
+ <font-icon icon="question-circle"/>
366
+ </span>
367
+ </label>
368
+ <div class="" :class="boxClassName">
369
+ <input type="file" class="d-none c-file-input"
370
+ :accept="acceptedFileTypes"
371
+ :multiple="bStyleConfig.fileMultiple || props.multiple"
372
+ ref="fileInputRef"
373
+ @change="onFileSelected" />
374
+ <input type="text" :class="bStyleConfig.fileClass" readonly
375
+ title="點擊選擇檔案" v-tooltip
376
+ ref="textInputRef"
377
+ :placeholder="props.placeholder"
378
+ v-show="showSingleFilePicker"
379
+ v-form-invalid="fieldModel"
380
+ @click="doShowFilePicker" />
381
+ <button type="button" class="input-group-text btn btn-outline-secondary c-single-file"
382
+ title="點擊選擇檔案" v-tooltip
383
+ v-show="showSingleFilePicker"
384
+ @click.prevent.stop="doShowFilePicker">
385
+ <font-icon icon="file-arrow-up"/>
386
+ </button>
387
+ <!-- 移除按鈕 -->
388
+ <!-- <button type="button" class="input-group-text btn btn-danger c-single-file"-->
389
+ <!-- title="清除檔案" v-tooltip-->
390
+ <!-- v-if="selectedFile"-->
391
+ <!-- @click.prevent.stop="doReset">-->
392
+ <!-- <i class="fa-solid fa-xmark"></i>-->
393
+ <!-- </button>-->
394
+ <button type="button" class="btn btn-outline-secondary c-multiple-file"
395
+ :disabled="isOverFileCountLimit"
396
+ @click.prevent.stop="doShowFilePicker">
397
+ <font-icon icon="file-arrow-up"/>
398
+ 選擇檔案
399
+ </button>
400
+ </div>
401
+ </div>
402
+ <!-- 錯誤訊息 -->
403
+ <div v-if="fieldModel?.errorMessage" :class="bStyleConfig.errorClass">
404
+ {{ fieldModel.errorMessage }}
405
+ </div>
406
+ <!-- 待上傳檔案列表 -->
407
+ <div v-if="selectedFileList.length" class="mt-3 c-file-upload-list">
408
+ <h6>待上傳檔案</h6>
409
+ <ul class="list-group">
410
+ <li v-for="file in selectedFileList" :key="file.name" class="list-group-item d-flex align-items-center">
411
+ <span class="me-2">{{ file.name }}</span>
412
+ <button class="btn btn-sm btn-outline-primary me-2"
413
+ @click.prevent.stop="doUploadFile(file)"
414
+ v-show="uploadProgressMap[file.name].readyToUpload()">上傳</button>
415
+ <button class="btn btn-sm btn-outline-danger me-2"
416
+ :disabled="disableRemoveSelectedFile(file)"
417
+ @click.prevent.stop="removeSelectedFile(file.name)">移除</button>
418
+ <div class="flex-grow-1" v-if="uploadProgressMap[file.name].isShowProgress()">
419
+ <div class="progress">
420
+ <div class="progress-bar" role="progressbar"
421
+ :style="{ width: uploadProgressMap[file.name].progress + '%' }"
422
+ :aria-valuenow="uploadProgressMap[file.name].progress" aria-valuemin="0" aria-valuemax="100">
423
+ </div>
424
+ </div>
425
+ </div>
426
+ </li>
427
+ </ul>
428
+ </div>
429
+ <!-- 已上傳檔案列表 -->
430
+ <div v-if="uploadedFileList.length" class="mt-3">
431
+ <h6 v-if="props.multiple">已上傳檔案</h6>
432
+ <ul class="list-group">
433
+ <li v-for="fileItem in uploadedFileList" :key="fileItem.name" class="list-group-item d-flex align-items-center">
434
+ <component :is="fileItem.url ? 'a' : 'span'"
435
+ :href="fileItem.url"
436
+ :download="fileItem.name"
437
+ target="_blank"
438
+ rel="noopener noreferrer"
439
+ class="c-file-download-link me-2">
440
+ <font-icon icon="file-lines" class="me-1"/>
441
+ <span>{{ fileItem.name }}</span>
442
+ </component>
443
+ <button class="btn btn-sm btn-outline-danger ms-auto" @click.prevent.stop="removeUploadedFile(fileItem)">移除</button>
444
+ </li>
445
+ </ul>
446
+ </div>
447
+ </template>
448
+
449
+ <style scoped>
450
+ .c-single-file-box .c-multiple-file{
451
+ display: none;
452
+ }
453
+ .c-multiple-file-box .c-single-file {
454
+ display: none;
455
+ }
456
+
457
+ .c-file-upload-list .progress {
458
+ height: 8px;
459
+ }
460
+
461
+
462
+ .c-file-download-link:is(a) {
463
+ color: var(--bs-link-color);
464
+ cursor: pointer;
465
+ }
466
+ .c-file-download-link:is(a):hover {
467
+ color: var(--bs-link-hover-color);
468
+ text-decoration: underline;
469
+ }
470
+
471
+ </style>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import { useRadioFormField } from '../../composables/useRadioFormField'
3
+ import { COptionItem } from '../../model/FormOptions'
4
+ import { IBSFieldStyleConfig } from '../../model/BSFieldStyleConfig'
5
+
6
+ const props = defineProps<{
7
+ label?: string
8
+ required?: boolean
9
+ name: string
10
+ optionList: COptionItem[]
11
+ readMode?: boolean
12
+ styleConfig?: IBSFieldStyleConfig
13
+ }>()
14
+
15
+ const {
16
+ useOptionList,
17
+ bStyleConfig,
18
+ fieldModel,
19
+ fieldRef,
20
+ isRequired,
21
+ isReadMode,
22
+ selectedText,
23
+ hasFormFieldError
24
+ } = useRadioFormField(props)
25
+ </script>
26
+
27
+ <template>
28
+ <div :class="bStyleConfig.containerClass">
29
+ <label v-if="props.label && !bStyleConfig.hideLabel" :class="bStyleConfig.labelClass">
30
+ {{props.label}}
31
+ <span v-if="isRequired" :class="bStyleConfig.requiredLabelClass">{{bStyleConfig.requiredLabelText}}</span>
32
+ </label>
33
+ <template v-if="isReadMode">
34
+ <div>
35
+ <span :class="bStyleConfig.plainTextClass">
36
+ {{ selectedText }}
37
+ </span>
38
+ <input type="hidden" :name="props.name" v-model="fieldRef"/>
39
+ </div>
40
+ </template>
41
+ <template v-else>
42
+ <div>
43
+ <div :class="bStyleConfig.radioWrapperClass"
44
+ v-for="optionItem in useOptionList" :key="optionItem.id">
45
+ <input type="radio" :class="bStyleConfig.radioClass"
46
+ :id="`radio-${props.name}-${optionItem.id}`"
47
+ :name="props.name" :value="optionItem.value"
48
+ v-model="fieldRef"
49
+ v-form-invalid="fieldModel">
50
+ <label :class="bStyleConfig.radioLabelClass" :for="`radio-${props.name}-${optionItem.id}`">{{optionItem.text}}</label>
51
+ </div>
52
+ <div v-if="hasFormFieldError(fieldModel)" :class="bStyleConfig.errorClass">
53
+ {{ fieldModel.errorMessage }}
54
+ </div>
55
+ </div>
56
+ </template>
57
+ </div>
58
+ </template>
59
+
60
+ <style scoped>
61
+
62
+ </style>