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,869 @@
1
+ import axios, {AxiosInstance, AxiosProgressEvent, AxiosRequestConfig, AxiosResponse, RawAxiosResponseHeaders, InternalAxiosRequestConfig} from "axios";
2
+ import {extension} from 'mime-types';
3
+ import {formatDatesInObject} from "../utils/CToolUtils";
4
+ import {AccessToken} from "../model/TokenUser";
5
+ import log from 'loglevel';
6
+ import _ from "lodash";
7
+
8
+ /**
9
+ * 定義常見的錯誤訊息對應
10
+ */
11
+ const commonErrorMessageMap : Record<number, string> = {
12
+ 400: '請求格式錯誤',
13
+ 401: '未授權訪問',
14
+ 403: '操作權限不足',
15
+ 404: '資源未找到',
16
+ 500: '服務器內部錯誤',
17
+ 503: '服務不可用',
18
+ };
19
+
20
+ /**
21
+ * ApiEndpoint 定義 API 端點的結構
22
+ */
23
+ export interface ApiEndpoint {
24
+ path: string; // API 路徑
25
+ method: string; // HTTP 方法
26
+ isAuthenticated?: boolean; // 是否需要身份驗證,預設為 true
27
+ security?: string[]; // 安全定義,例如 'bearerAuth'
28
+ withCredentials?: boolean; // 是否攜帶憑證,同時會設定 axios 的 withCredentials
29
+ isRefreshTokenEndpoint?: boolean; // 是否為刷新權杖的端點
30
+ errorMessageMap?: Record<number, string>; // 錯誤訊息對應
31
+ noSpinner?: boolean; // 是否不顯示載入動畫
32
+ headers?: Record<string, string>; // 預設標頭
33
+ metaData?: Record<string, any>; // 額外的元資料
34
+ }
35
+
36
+ /**
37
+ * CreateEndpointOptions 定義建立 ApiEndpoint 時的選項
38
+ * 通常用於設定預設值
39
+ */
40
+ export interface CreateEndpointOptions {
41
+ isAuthenticated?: boolean;
42
+ security?: string[];
43
+ headers?: Record<string, string>;
44
+ }
45
+
46
+ /**
47
+ * 建立 ApiEndpoint,並合併預設值
48
+ * @param data
49
+ * @param options
50
+ */
51
+ const createEndpoint = (data: ApiEndpoint, options?: Partial<CreateEndpointOptions>) : ApiEndpoint => {
52
+ return {
53
+ path: data.path,
54
+ method: data.method,
55
+ isAuthenticated: _.defaultTo(data.isAuthenticated, options?.isAuthenticated),
56
+ security: _.defaultTo(data.security, options?.security),
57
+ withCredentials: _.defaultTo(data.withCredentials, false),
58
+ isRefreshTokenEndpoint: data.isRefreshTokenEndpoint || false,
59
+ errorMessageMap: Object.assign({}, commonErrorMessageMap, data.errorMessageMap),
60
+ noSpinner: data.noSpinner || false,
61
+ headers: data.headers || options?.headers || {},
62
+ metaData: data.metaData || {},
63
+ }
64
+ }
65
+
66
+ /**
67
+ * ApiRequest 定義 API 請求的結構
68
+ */
69
+ export class ApiRequest {
70
+ endpointKey: string = ''; // API endpoint key
71
+ headers?: Record<string, string>; // 自訂標頭
72
+ pathParam?: Record<string, any>; // 路徑參數
73
+ queryParam?: Record<string, any>; // 查詢參數
74
+ postBody?: Record<string, any> | FormData; // 請求體
75
+ isDownloadMode?: boolean; // 是否為下載檔案
76
+ downloadFileName?: string; // 下載檔案名稱
77
+ // 上傳進度回調函式
78
+ onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
79
+ axiosConfig?: AxiosRequestConfig; // 額外的 Axios 配置
80
+ noSpinner?: boolean; // 是否不顯示載入動畫
81
+
82
+ // ~ ----------------------------------------------------------
83
+ // ~ constructor
84
+
85
+ constructor(data: ApiRequest) {
86
+ Object.assign(this, data);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * ApiResponse 定義 API 回應的結構
92
+ */
93
+ export class ApiResponse {
94
+ httpStatus: number = 0; // HTTP 狀態碼
95
+ status?: number;
96
+ message?: string; // 錯誤訊息
97
+ data?: any; // 返回的數據
98
+ nativeError?: any; // 原生錯誤對象
99
+ paging?: Record<string, any>; // 分頁資訊
100
+ blobData?: Blob; // 下載的檔案資料
101
+ headers?: RawAxiosResponseHeaders; // 回應標頭
102
+ details?: any; // 其他詳細資訊
103
+ axiosResponse?: AxiosResponse; // 原生 Axios 回應對象
104
+
105
+ constructor(data?: Partial<ApiResponse>) {
106
+ if(data) {
107
+ Object.assign(this, data);
108
+ }
109
+ }
110
+
111
+ isOk(): boolean {
112
+ // 判斷 httpStatus 是否在 200-299 範圍內,且 code 是否為 0
113
+ return this.httpStatus >= 200 && this.httpStatus < 300;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * ApiRejectError 定義 API 呼叫失敗時的自訂錯誤
119
+ * 此物件給 ApiService.call 呼叫後,並且回傳 Promise.reject() 使用
120
+ */
121
+ export class ApiRejectError extends Error {
122
+ response: ApiResponse;
123
+ type?: string; // 錯誤類型,例如 'notFound'
124
+
125
+ constructor(response: ApiResponse) {
126
+ // 將錯誤訊息傳遞給父類別 Error
127
+ super(response?.message || 'API Error');
128
+ // 設定錯誤名稱
129
+ this.name = 'ApiError';
130
+ // 保存完整的 ApiResponse 物件
131
+ this.response = response;
132
+ }
133
+
134
+ notFound(): this {
135
+ this.type = 'notFound';
136
+ return this;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * ConfigMetaData 定義 Axios 請求的額外元資料
142
+ */
143
+ type ConfigMetaData = {
144
+ endpoint?: ApiEndpoint;
145
+ request?: ApiRequest;
146
+ }
147
+
148
+ /**
149
+ * ApiService 提供呼叫後端 API 的功能
150
+ */
151
+ export class ApiService {
152
+
153
+ // 靜態屬性,保存配置選項
154
+ private static _options: {
155
+ debugMode?: boolean; // 是否啟用除錯模式
156
+ baseUrl: string;
157
+ contextPath: string;
158
+ proxyEnable?: boolean;
159
+ proxyBaseUrl?: string;
160
+ showSpinnerFunc?: () => void;
161
+ hideSpinnerFunc?: () => void;
162
+ showErrorToastFunc?: (param: {title?:string,content?:string}) => void;
163
+ createEndpointOptions?: CreateEndpointOptions;
164
+ refreshTokenFunc?: () => Promise<AccessToken>;
165
+ onAuthenticateFailed?: (param: {apiEndpoint: ApiEndpoint, apiRequest: ApiRequest}) => void; // 認證失敗時的回調函式
166
+ } = {
167
+ baseUrl: '',
168
+ contextPath: ''
169
+ };
170
+
171
+ // 靜態屬性,保存已註冊的 API 端點
172
+ private static _apiEndpoints: Record<string, ApiEndpoint> = {};
173
+ // 靜態屬性,自訂標頭
174
+
175
+ private static _customHeaders: Record<string, string> = {};
176
+ private static _customHeaderProvider?: (endpoint: ApiEndpoint, header: Record<string, any>) => Promise<Record<string, string>>;
177
+ // 靜態屬性,自訂錯誤訊息對應
178
+ private static _customErrorMessageMap: Record<number, string> = {};
179
+
180
+ // 靜態屬性,存取權杖
181
+ private static _accessToken?: AccessToken;
182
+ private static _accessTokenProvider?: () => Promise<AccessToken | undefined>;
183
+ // 靜態屬性,刷新權杖的 Promise
184
+ private static _refreshTokenPromise?: Promise<AccessToken>;
185
+ // 靜態屬性,axios 實例
186
+ private static _axiosInstance?: AxiosInstance;
187
+ // 靜態屬性,暫存失敗的請求佇列
188
+ private static _failedRequestQueue: Array<{
189
+ resolve: (value: any) => void;
190
+ reject: (error: any) => void;
191
+ config: AxiosRequestConfig;
192
+ }> = [];
193
+ // 靜態屬性,是否正在刷新 token
194
+ private static _isRefreshing = false;
195
+
196
+ static set accessToken(token: AccessToken) {
197
+ ApiService._accessToken = token;
198
+ }
199
+
200
+ static set accessTokenProvider(provider: () => Promise<AccessToken | undefined>) {
201
+ ApiService._accessTokenProvider = provider;
202
+ }
203
+
204
+ static async getAccessToken(): Promise<AccessToken | undefined> {
205
+ if(ApiService._accessTokenProvider) {
206
+ const token = await ApiService._accessTokenProvider();
207
+ if(!token) {
208
+ return undefined;
209
+ }
210
+ if(token instanceof AccessToken) {
211
+ ApiService._accessToken = token;
212
+ } else {
213
+ ApiService._accessToken = new AccessToken(token);
214
+ }
215
+ return ApiService._accessToken;
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ /**
221
+ * 靜態方法,新增全域自訂標頭
222
+ * @param key
223
+ * @param value
224
+ */
225
+ static addHeader(key: string, value: string) {
226
+ ApiService._customHeaders[key] = value;
227
+ }
228
+
229
+ /**
230
+ * 靜態方法,移除全域自訂標頭
231
+ * @param key
232
+ */
233
+ static removeHeader(key: string) {
234
+ delete ApiService._customHeaders[key];
235
+ }
236
+
237
+ /**
238
+ * 靜態方法,設定自訂標頭提供者
239
+ * @param provider
240
+ */
241
+ static customHeaderProvider(provider: (endpoint: ApiEndpoint, header: Record<string, any>) => Promise<Record<string, string>>) {
242
+ ApiService._customHeaderProvider = provider;
243
+ }
244
+
245
+ // ~ ----------------------------------------------------------
246
+ // ~ constructor
247
+
248
+ constructor() {
249
+ }
250
+
251
+ // ~ ----------------------------------------------------------
252
+
253
+ /**
254
+ * 靜態方法,配置 ApiService
255
+ * 為方便管理,建議在專案啟動時呼叫此方法註冊所有端點
256
+ * 可以配置
257
+ * - debugMode: 是否啟用除錯模式
258
+ * - baseUrl: API 伺服器的基礎 URL
259
+ * - contextPath: API 的上下文路徑
260
+ * - proxyEnable: 是否啟用代理
261
+ * - proxyBaseUrl: 代理的基礎 URL
262
+ * - showSpinnerFunc: 顯示全域載入動畫的函式
263
+ * - hideSpinnerFunc: 隱藏全域載入動畫的函式
264
+ * - showErrorToastFunc: 顯示全域錯誤提示的函式
265
+ * - endpoints: 預設註冊的 API 端點
266
+ * - customHeaders: 全域自訂標頭
267
+ * - customErrorMessageMap: 全域自訂錯誤訊息對應
268
+ * - createEndpointOptions: 建立端點時的預設選項
269
+ * - refreshTokenFunc: 刷新存取權杖的函式
270
+ * - onAuthenticateFailed: 認證失敗時的回調函式
271
+ * @param config
272
+ */
273
+ static configure(config:{
274
+ debugMode?: boolean;
275
+ baseUrl?: string;
276
+ contextPath?: string;
277
+ proxyEnable?: boolean;
278
+ proxyBaseUrl?: string;
279
+ showSpinnerFunc?: () => void;
280
+ hideSpinnerFunc?: () => void;
281
+ showErrorToastFunc?: (param: {title?:string,content?:string}) => void;
282
+ endpoints: Record<string, ApiEndpoint>;
283
+ customHeaders?: Record<string, string>;
284
+ customHeaderProvider?: (endpoint: ApiEndpoint, header: Record<string, any>) => Promise<Record<string, string>>;
285
+ customErrorMessageMap?: Record<number, string>;
286
+ createEndpointOptions?: CreateEndpointOptions;
287
+ refreshTokenFunc?: () => Promise<AccessToken>;
288
+ onAuthenticateFailed?: (param: {apiEndpoint: ApiEndpoint, apiRequest: ApiRequest}) => void;
289
+ }) {
290
+ // 設定靜態屬性
291
+ ApiService._options = Object.assign(ApiService._options, {
292
+ debugMode: config.debugMode || false,
293
+ baseUrl: config.baseUrl || '',
294
+ contextPath: config.contextPath || '',
295
+ proxyEnable: config.proxyEnable,
296
+ proxyBaseUrl: config.proxyBaseUrl,
297
+ showSpinnerFunc: config.showSpinnerFunc,
298
+ hideSpinnerFunc: config.hideSpinnerFunc,
299
+ showErrorToastFunc: config.showErrorToastFunc,
300
+ createEndpointOptions: config.createEndpointOptions,
301
+ refreshTokenFunc: config.refreshTokenFunc,
302
+ onAuthenticateFailed: config.onAuthenticateFailed
303
+ });
304
+
305
+ // setup logger
306
+ const logger = log.getLogger('ApiService');
307
+ logger.setLevel( ApiService._options.debugMode ? log.levels.DEBUG : log.levels.WARN );
308
+
309
+ // 設定全域自訂標頭提供者
310
+ if(config.customHeaderProvider) {
311
+ ApiService._customHeaderProvider = config.customHeaderProvider;
312
+ logger.debug('ApiService customHeaderProvider set.');
313
+ }
314
+
315
+ // 逐一新增端點
316
+ Object.keys(config.endpoints).forEach(key => {
317
+ const endpointData = config.endpoints![key];
318
+ // 如果有 customHeaders,則把 customHeaders 當作基底合併
319
+ if(config.customHeaders) {
320
+ endpointData.headers = Object.assign({},
321
+ config.customHeaders,
322
+ endpointData.headers || {}
323
+ );
324
+ }
325
+ // 如果有 customErrorMessageMap,則把 customErrorMessageMap 當作基底合併
326
+ if(config.customErrorMessageMap) {
327
+ endpointData.errorMessageMap = Object.assign({},
328
+ config.customErrorMessageMap,
329
+ endpointData.errorMessageMap || {}
330
+ );
331
+ }
332
+ // 新增端點
333
+ ApiService.addEndpoints(key, endpointData);
334
+ });
335
+
336
+ // 初始化 axios 攔截器
337
+ ApiService.setupAxiosInterceptors();
338
+ }
339
+
340
+ /**
341
+ * 靜態方法,新增單一 API 端點
342
+ * @param key
343
+ * @param endpoint
344
+ */
345
+ static addEndpoints(key: string, endpoint: ApiEndpoint) {
346
+ if(!ApiService._apiEndpoints) {
347
+ ApiService._apiEndpoints = {};
348
+ }
349
+ if(ApiService._apiEndpoints.hasOwnProperty(key)) {
350
+ console.log(`API endpoint key:${key} already exists, skip adding.`);
351
+ return;
352
+ }
353
+ ApiService._apiEndpoints[key] = createEndpoint(endpoint, ApiService._options.createEndpointOptions);
354
+ }
355
+
356
+ /**
357
+ * 靜態方法,方便直接呼叫 API
358
+ * @param request
359
+ */
360
+ static call(request: ApiRequest): Promise<ApiResponse> {
361
+ const service = new ApiService();
362
+ return service.callApi(request);
363
+ }
364
+
365
+ /**
366
+ * 靜態方法,方便直接下載檔案
367
+ * @param request
368
+ */
369
+ static download(request: ApiRequest): Promise<void> {
370
+ const service = new ApiService();
371
+ return service.downloadFile(request);
372
+ }
373
+
374
+ /**
375
+ * 取得指定的 API 端點資訊
376
+ * @param endpointKey
377
+ */
378
+ getEndpoint(endpointKey: string): ApiEndpoint | undefined {
379
+ return ApiService._apiEndpoints[endpointKey];
380
+ }
381
+
382
+ /**
383
+ * 取得完整的 API URL
384
+ * @param endpointKey
385
+ */
386
+ getFullUrl(endpointKey: string): string | undefined {
387
+ const endpoint = this.getEndpoint(endpointKey);
388
+ if (endpoint) {
389
+ return this.makeBaseUrl(endpoint);
390
+ }
391
+ return undefined;
392
+ }
393
+
394
+ /**
395
+ * 組合完整的 API URL
396
+ * @param endpoint
397
+ */
398
+ makeBaseUrl(endpoint: ApiEndpoint): string | undefined {
399
+ // 判斷是否啟用代理
400
+ const proxyEnable = ApiService._options.proxyEnable || false;
401
+ let baseUrl = ApiService._options.baseUrl || '';
402
+ if(proxyEnable) {
403
+ // 如果啟用代理,則使用代理的 base URL (VITE_API_PROXY_BASE_URL)
404
+ baseUrl = ApiService._options.proxyBaseUrl || '';
405
+ }
406
+ // 取得 API context path
407
+ const baseContext = ApiService._options.contextPath || '';
408
+ if( !baseUrl || !baseContext || _.isEmpty(baseUrl) || _.isEmpty(baseContext) ) {
409
+ console.warn('API base URL or context is not defined.');
410
+ return undefined;
411
+ }
412
+ if (endpoint) {
413
+ return `${baseUrl}${baseContext}${endpoint.path}`;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * 呼叫 API
419
+ * @param request
420
+ */
421
+ async callApi(request: ApiRequest): Promise<ApiResponse> {
422
+ const endpoint = this.getEndpoint(request.endpointKey);
423
+ if (!endpoint) {
424
+ return Promise.reject(new Error(`API endpoint ${request.endpointKey} not found`));
425
+ }
426
+
427
+ let url = this.makeBaseUrl(endpoint);
428
+ if (!url) {
429
+ return Promise.reject(new Error(`Invalid URL for endpoint ${request.endpointKey}`));
430
+ }
431
+
432
+ // 取得 logger
433
+ const logger = log.getLogger('ApiService');
434
+
435
+ // 替換 path 參數
436
+ if (request.pathParam) {
437
+ Object.keys(request.pathParam).forEach(key => {
438
+ if(_.isNil(request.pathParam)) {
439
+ return;
440
+ }
441
+ url = url?.replace(`{${key}}`, encodeURIComponent(request.pathParam[key]));
442
+ });
443
+ }
444
+
445
+ // 處理 postBody, 例如:轉換 Date 物件為 ISO 字串
446
+ let usePostBody;
447
+ if(request.postBody && request.postBody instanceof FormData) {
448
+ usePostBody = request.postBody;
449
+ } else {
450
+ usePostBody = formatDatesInObject(_.cloneDeep(request.postBody));
451
+ }
452
+
453
+ // 處理自訂標頭
454
+ let dynamicHeaders: Record<string, string> = {};
455
+ if(ApiService._customHeaderProvider) {
456
+ dynamicHeaders = await ApiService._customHeaderProvider(endpoint, ApiService._customHeaders) || {};
457
+ }
458
+
459
+ // 準備 Axios 請求配置
460
+ const config: AxiosRequestConfig & { metadata?: ConfigMetaData } = Object.assign({}, {
461
+ url: url,
462
+ method: endpoint.method,
463
+ headers: Object.assign(ApiService._customHeaders || {},
464
+ endpoint.headers || {},
465
+ request.headers || {},
466
+ dynamicHeaders
467
+ ),
468
+ data: usePostBody,
469
+ params: request.queryParam,
470
+ withCredentials: endpoint.withCredentials || false,
471
+ onUploadProgress: request.onUploadProgress,
472
+ // 添加 metadata 供攔截器使用
473
+ metadata: {
474
+ endpoint: endpoint,
475
+ request: request
476
+ }
477
+ }, request.axiosConfig || {});
478
+ if(request.isDownloadMode) {
479
+ config.responseType = 'blob';
480
+ }
481
+
482
+ const cOpt = ApiService._options;
483
+ const axiosInstance = ApiService.getAxiosInstance();
484
+
485
+ // 顯示 global spinner
486
+ const needToShowSpinner = !(endpoint.noSpinner) || request.noSpinner === false;
487
+ if (needToShowSpinner) {
488
+ cOpt.showSpinnerFunc?.();
489
+ }
490
+
491
+ return axiosInstance(config)
492
+ .then((response: AxiosResponse) => {
493
+ if(request.isDownloadMode) {
494
+ // 下載檔案
495
+ return new ApiResponse({
496
+ httpStatus: response.status,
497
+ blobData: response.data,
498
+ headers: response.headers,
499
+ axiosResponse: response
500
+ });
501
+ }
502
+ // 一般 API 呼叫,回應 JSON 格式
503
+ return new ApiResponse({
504
+ httpStatus: response.status,
505
+ status: response.data.status,
506
+ message: response.data.message,
507
+ data: response.data,
508
+ paging: response.data.paging,
509
+ axiosResponse: response
510
+ });
511
+ })
512
+ .finally(() => {
513
+ // 隱藏 global spinner
514
+ if (needToShowSpinner) {
515
+ cOpt.hideSpinnerFunc?.();
516
+ }
517
+ })
518
+ .catch((error: any) => {
519
+ console.error('API call failed:', error);
520
+ // 處理錯誤
521
+ const apiResp = error.response;
522
+
523
+ // 顯示錯誤提示(401 錯誤已由攔截器處理)
524
+ const msg = _.get(apiResp, 'data.message')
525
+ || endpoint.errorMessageMap?.[error.response?.status]
526
+ || 'API 呼叫失敗';
527
+ cOpt.showErrorToastFunc?.({
528
+ title: '錯誤',
529
+ content: msg
530
+ });
531
+ return new ApiResponse({
532
+ httpStatus: apiResp?.status || 500,
533
+ status: _.get(apiResp, 'data.status'),
534
+ message: _.get(apiResp, 'data.message') || msg,
535
+ data: null,
536
+ details: _.get(apiResp, 'data.details'),
537
+ nativeError: error
538
+ });
539
+ });
540
+ }
541
+
542
+ /**
543
+ * 下載檔案
544
+ * @param request
545
+ */
546
+ async downloadFile(request: ApiRequest): Promise<void> {
547
+ request.isDownloadMode = true;
548
+ const response = await this.callApi(request);
549
+ if(!response.isOk() || !response.blobData) {
550
+ return Promise.reject(new ApiRejectError(response));
551
+ }
552
+ // 下載檔案
553
+ // 決定下載檔案名稱: 指定檔案名稱
554
+ let exportFileName = this.resolveExportFileName(request, response);
555
+
556
+ // 使用 a 標籤下載檔案
557
+ const url = window.URL.createObjectURL(response.blobData);
558
+ const link = document.createElement('a');
559
+ link.href = url;
560
+ link.setAttribute('download', exportFileName);
561
+ document.body.appendChild(link);
562
+ link.click();
563
+ document.body.removeChild(link);
564
+ window.URL.revokeObjectURL(url);
565
+ return Promise.resolve();
566
+ }
567
+
568
+ /**
569
+ * 決定下載檔案名稱
570
+ * @param request
571
+ * @param response
572
+ */
573
+ resolveExportFileName(request: ApiRequest, response: ApiResponse): string {
574
+ let exportFileName = request.downloadFileName;
575
+ // 若沒有指定檔案名稱,從 Content-Disposition 標頭取得檔案名稱
576
+ if(!exportFileName) {
577
+ const dispositionHeaderVal = response.headers?.['content-disposition'] as string || '';
578
+ // RFC 5987 支援的 filename*
579
+ let match = dispositionHeaderVal.match(/filename\*=UTF-8''([^;"']+)/i);
580
+ if(match && match.length > 1) {
581
+ return decodeURIComponent(match[1]);
582
+ }
583
+ // 傳統的 filename 使用 RFC 2047 編碼
584
+ match = dispositionHeaderVal.match(/filename=["']?([^;"']+)/i);
585
+ if (match && match[1]) {
586
+ return this.decodeRfc2047(match[1]);
587
+ }
588
+ }
589
+ // 若仍沒有檔案名稱,使用預設名稱
590
+ if(!exportFileName) {
591
+ exportFileName = 'downloaded_file';
592
+ }
593
+ // 若是 response header 有 content-type,且檔案名稱沒有副檔名或副檔名不一致,則根據 content-type 補上副檔名
594
+ const contentType = response.headers?.['content-type'] as string || '';
595
+ if(_.isNil(contentType) || _.isEmpty(contentType)) {
596
+ return exportFileName;
597
+ }
598
+ const mime = contentType.split(';')[0]; // 去掉可能的 charset 資訊
599
+ const expectedExtension = extension(mime);
600
+ if(!expectedExtension) {
601
+ return exportFileName;
602
+ }
603
+ const fa = exportFileName.split('.');
604
+ if(fa.length === 1) {
605
+ // 沒有副檔名,直接加上
606
+ exportFileName += `.${expectedExtension}`;
607
+ } else {
608
+ const currentExtension = fa[fa.length - 1];
609
+ const baseName = fa.slice(0, fa.length - 1).join('.');
610
+ if(currentExtension.toLowerCase() !== expectedExtension.toLowerCase()) {
611
+ // 副檔名不一致,改用正確的副檔名
612
+ exportFileName = `${baseName}.${expectedExtension}`;
613
+ }
614
+ }
615
+ return exportFileName;
616
+ }
617
+
618
+ /**
619
+ * 解碼 RFC 2047 編碼的字串
620
+ * @param str RFC 2047 編碼的字串
621
+ */
622
+ decodeRfc2047(str: string): string {
623
+ const match = str.match(/=\?([^?]+)\?([bqBQ])\?([^?]+)\?=/);
624
+ if (!match) {
625
+ return str;
626
+ }
627
+ const charset = match[1].toLowerCase();
628
+ const encoding = match[2].toUpperCase();
629
+ let text = match[3];
630
+
631
+ if (encoding === 'B') {
632
+ // Base64
633
+ const decoded = atob(text);
634
+ return new TextDecoder(charset).decode(Uint8Array.from(decoded, c => c.charCodeAt(0)));
635
+ } else if (encoding === 'Q') {
636
+ // Quoted-Printable
637
+ text = text.replace(/_/g, ' ').replace(/=([A-Fa-f0-9]{2})/g, (_, hex) =>
638
+ String.fromCharCode(parseInt(hex, 16))
639
+ );
640
+ return new TextDecoder(charset).decode(new Uint8Array(Array.from(text, c => c.charCodeAt(0))));
641
+ }
642
+ return str;
643
+ }
644
+
645
+ /**
646
+ * 檢查是否啟用 Bearer Token 認證
647
+ */
648
+ private static hasBearerAuth(): boolean {
649
+ return this._options.createEndpointOptions?.security?.includes('bearerAuth') ?? false;
650
+ }
651
+
652
+ /**
653
+ * 設定 axios 攔截器
654
+ */
655
+ private static setupAxiosInterceptors(): void {
656
+ // 如果已經設定過攔截器,則不重複設定
657
+ if (ApiService._axiosInstance) {
658
+ return;
659
+ }
660
+
661
+ // 建立 axios 實例
662
+ ApiService._axiosInstance = axios.create();
663
+
664
+ // 只有啟用 Bearer Token 認證時才設定 interceptors
665
+ if (this.hasBearerAuth()) {
666
+ this.setupAuthInterceptorsOnBearerAuth();
667
+ }
668
+ }
669
+
670
+ /**
671
+ * 設定認證相關的 interceptors
672
+ */
673
+ private static setupAuthInterceptorsOnBearerAuth(): void {
674
+ if (!ApiService._axiosInstance) {
675
+ return;
676
+ }
677
+
678
+ const logger = log.getLogger('ApiService');
679
+
680
+ // 請求攔截器 - 自動添加 Authorization header
681
+ ApiService._axiosInstance.interceptors.request.use(
682
+ async (config: InternalAxiosRequestConfig & { metadata?: ConfigMetaData }) => {
683
+ // 根據 metadata 取得 endpoint 資訊
684
+ const metadata = config.metadata as ConfigMetaData | undefined;
685
+ const accessToken = await ApiService.getAccessToken();
686
+
687
+ // 如果 endpoint 需要認證且有 access token
688
+ if (metadata?.endpoint?.isAuthenticated && accessToken && config.headers && !config.headers['Authorization']) {
689
+ // 檢查 token 是否即將過期,當過期則try 刷新 token
690
+ if (accessToken.isExpired()) {
691
+ try {
692
+ await ApiService.refreshTokenIfNeeded();
693
+ const updatedToken = await ApiService.getAccessToken();
694
+ if(updatedToken) {
695
+ config.headers['Authorization'] = updatedToken.bearerToken;
696
+ }
697
+ }catch (error) {
698
+ // Token 刷新失敗,中止請求
699
+ // 當發生錯誤,則觸發認證失敗處理
700
+ ApiService.handleAuthenticationFailed(error, config);
701
+ return Promise.reject(error); // 這會中止該請求
702
+ }
703
+ } else {
704
+ config.headers['Authorization'] = accessToken.bearerToken;
705
+ }
706
+ }
707
+ return config;
708
+ },
709
+ (error: any) => Promise.reject(error)
710
+ );
711
+
712
+ // 回應攔截器 - 處理 401 錯誤和 token 刷新
713
+ ApiService._axiosInstance.interceptors.response.use(
714
+ (response: AxiosResponse) => response,
715
+ async (error: any) => {
716
+ // 如果 error is 不是來自 axios,直接拒絕
717
+ if (!error.config) {
718
+ return Promise.reject(error);
719
+ }
720
+ // 取得原始請求配置
721
+ const originalRequest = error.config;
722
+
723
+ if(ApiService._options.debugMode) {
724
+ console.log('API response error intercepted:', error);
725
+ }
726
+
727
+ // 從 config 中取得 endpoint 和 request 資訊(如果有的話)
728
+ const metadata = originalRequest.metadata as ConfigMetaData | undefined;
729
+ // 只有當 endpoint 需要認證時才處理 401 錯誤
730
+ if (!metadata?.endpoint?.isAuthenticated) {
731
+ return Promise.reject(error);
732
+ }
733
+
734
+ // 檢查是否為 401 錯誤且尚未重試過
735
+ if (error.response?.status === 401 && !originalRequest._retry) {
736
+ originalRequest._retry = true;
737
+
738
+ // 如果正在刷新 token,將請求加入佇列
739
+ if (ApiService._isRefreshing) {
740
+ if(ApiService._options.debugMode) {
741
+ console.log('Token is being refreshed, queuing request.');
742
+ }
743
+ return new Promise((resolve, reject) => {
744
+ ApiService._failedRequestQueue.push({
745
+ resolve,
746
+ reject,
747
+ config: originalRequest
748
+ });
749
+ });
750
+ }
751
+
752
+ // 開始刷新 token
753
+ try {
754
+ ApiService._isRefreshing = true;
755
+ const newToken = await ApiService.refreshToken();
756
+
757
+ // 更新原始請求的 Authorization header
758
+ if (originalRequest.headers) {
759
+ originalRequest.headers['Authorization'] = newToken.bearerToken;
760
+ }
761
+
762
+ // 處理佇列中的失敗請求
763
+ ApiService.processFailedRequestQueue(null, newToken);
764
+
765
+ // 重試原始請求
766
+ return ApiService._axiosInstance!(originalRequest);
767
+ } catch (refreshError) {
768
+ console.error('Token refresh failed:', refreshError);
769
+ // 刷新失敗,處理佇列並觸發認證失敗
770
+ ApiService.processFailedRequestQueue(refreshError, null);
771
+ ApiService.handleAuthenticationFailed(error, originalRequest);
772
+ return Promise.reject(refreshError);
773
+ } finally {
774
+ ApiService._isRefreshing = false;
775
+ }
776
+ }
777
+
778
+ return Promise.reject(error);
779
+ }
780
+ );
781
+ }
782
+
783
+ /**
784
+ * 刷新 access token
785
+ */
786
+ static async refreshToken(): Promise<AccessToken> {
787
+ if (!ApiService._options.refreshTokenFunc) {
788
+ throw new Error('Refresh token function not configured');
789
+ }
790
+
791
+ try {
792
+ const newToken = await ApiService._options.refreshTokenFunc();
793
+ if(!newToken) {
794
+ throw new Error('Failed to refresh token: no token returned');
795
+ }
796
+ ApiService._accessToken = newToken;
797
+ return newToken;
798
+ } catch (error) {
799
+ // 清除 token
800
+ console.error('Token refresh failed:', error);
801
+ ApiService._accessToken = undefined;
802
+ throw error;
803
+ }
804
+ }
805
+
806
+ /**
807
+ * 檢查並刷新 token(如果需要)
808
+ */
809
+ private static async refreshTokenIfNeeded(): Promise<AccessToken> {
810
+ if (!ApiService._refreshTokenPromise) {
811
+ ApiService._refreshTokenPromise = ApiService.refreshToken()
812
+ .finally(() => {
813
+ ApiService._refreshTokenPromise = undefined;
814
+ });
815
+ }
816
+ if (ApiService._refreshTokenPromise) {
817
+ return ApiService._refreshTokenPromise;
818
+ } else {
819
+ return Promise.reject('No refresh token promise available');
820
+ }
821
+ }
822
+
823
+ /**
824
+ * 處理佇列中的失敗請求
825
+ */
826
+ private static processFailedRequestQueue(error: any, token: AccessToken | null): void {
827
+ ApiService._failedRequestQueue.forEach(({ resolve, reject, config }) => {
828
+ if (error) {
829
+ reject(error);
830
+ return;
831
+ }
832
+ // 更新請求的 Authorization header 並重試
833
+ if (token && config.headers) {
834
+ config.headers['Authorization'] = token.bearerToken;
835
+ }
836
+ resolve(ApiService._axiosInstance!(config));
837
+ });
838
+
839
+ // 清空佇列
840
+ ApiService._failedRequestQueue = [];
841
+ }
842
+
843
+ /**
844
+ * 處理認證失敗
845
+ */
846
+ private static handleAuthenticationFailed(_error: any, config: AxiosRequestConfig & { metadata?: ConfigMetaData }): void {
847
+ // 從 config 中取得 endpoint 和 request 資訊(如果有的話)
848
+ const metadata = config.metadata as ConfigMetaData | undefined;
849
+
850
+ ApiService._options.onAuthenticateFailed?.({
851
+ apiEndpoint: metadata?.endpoint || {} as ApiEndpoint,
852
+ apiRequest: metadata?.request || {} as ApiRequest
853
+ });
854
+ }
855
+
856
+ /**
857
+ * 取得 axios 實例
858
+ */
859
+ private static getAxiosInstance(): AxiosInstance {
860
+ if (!ApiService._axiosInstance) {
861
+ ApiService.setupAxiosInterceptors();
862
+ }
863
+ return ApiService._axiosInstance!;
864
+ }
865
+ }
866
+
867
+ // 防止 Vite/rollup tree-shake 掉 type export
868
+ export const __ApiServiceDefine__ = null as unknown as
869
+ ApiEndpoint;