@xfilecom/xframe 0.1.36 → 0.1.38
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/defaults.json +2 -2
- package/package.json +1 -1
- package/template/shared/endpoint/endpoint.ts +2 -1
- package/template/web/admin/src/App.tsx +5 -11
- package/template/web/client/src/App.tsx +3 -7
- package/template/web/shared/src/api/client.ts +42 -12
- package/template/web/shared/src/api/commonResponse.ts +80 -0
- package/template/web/shared/src/api/methods/health.method.ts +2 -2
- package/template/web/shared/src/context/StoreProvider.tsx +39 -1
- package/template/web/shared/src/hooks/useAppStore.ts +1 -0
- package/template/web/shared/src/hooks/useHealthStatus.ts +11 -15
- package/template/web/shared/src/index.ts +10 -1
- package/template/web/shared/src/methods/health.ts +3 -3
- package/template/web/shared/src/store/root-store.ts +61 -7
- package/template/web/shared/src/styles/app.css +23 -0
package/defaults.json
CHANGED
package/package.json
CHANGED
|
@@ -44,12 +44,13 @@ export const healthEndpoint: EndpointDef = {
|
|
|
44
44
|
path: '/health',
|
|
45
45
|
/** health 는 인디케이터 없이 조용히 호출하는 경우가 많음 */
|
|
46
46
|
showIndicator: false,
|
|
47
|
+
post: unwrapResponseData,
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
export const appMetaEndpoint: EndpointDef = {
|
|
50
51
|
method: 'GET',
|
|
51
52
|
path: '/app-meta',
|
|
52
|
-
|
|
53
|
+
/** 공통 `{ code, data, error }` 는 axios 인터셉터에서 `data` 로 치환 */
|
|
53
54
|
showIndicator: false,
|
|
54
55
|
timeoutMs: 10_000,
|
|
55
56
|
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Badge, Text } from '@xfilecom/front-core';
|
|
2
|
-
import { Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
|
|
2
|
+
import { safeJsonStringify, Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
|
|
3
3
|
|
|
4
4
|
const title = import.meta.env.VITE_APP_TITLE || '__PACKAGE_NAME__ (admin)';
|
|
5
5
|
|
|
6
6
|
export function App() {
|
|
7
|
-
const {
|
|
7
|
+
const { data } = useHealthStatus();
|
|
8
8
|
|
|
9
9
|
return (
|
|
10
10
|
<Shell
|
|
@@ -18,15 +18,9 @@ export function App() {
|
|
|
18
18
|
</>
|
|
19
19
|
}
|
|
20
20
|
>
|
|
21
|
-
{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</Text>
|
|
25
|
-
) : (
|
|
26
|
-
<Text as="pre" variant="small" style={{ margin: 0, overflow: 'auto' }}>
|
|
27
|
-
{text}
|
|
28
|
-
</Text>
|
|
29
|
-
)}
|
|
21
|
+
<Text as="pre" variant="small" style={{ margin: 0, overflow: 'auto' }}>
|
|
22
|
+
{data != null ? safeJsonStringify(data, 2) : '—'}
|
|
23
|
+
</Text>
|
|
30
24
|
</Shell>
|
|
31
25
|
);
|
|
32
26
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { Badge, Button, Stack, Text } from '@xfilecom/front-core';
|
|
3
|
-
import { Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
|
|
3
|
+
import { safeJsonStringify, Shell, useHealthStatus } from '__WEB_SHARED_WORKSPACE__';
|
|
4
4
|
import { FrontCoreShowcase } from './FrontCoreShowcase';
|
|
5
5
|
|
|
6
6
|
const title = import.meta.env.VITE_APP_TITLE || '__PACKAGE_NAME__';
|
|
@@ -9,7 +9,7 @@ type Tab = 'api' | 'atoms';
|
|
|
9
9
|
|
|
10
10
|
export function App() {
|
|
11
11
|
const [tab, setTab] = useState<Tab>('atoms');
|
|
12
|
-
const {
|
|
12
|
+
const { data } = useHealthStatus();
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
15
|
<Shell
|
|
@@ -44,13 +44,9 @@ export function App() {
|
|
|
44
44
|
|
|
45
45
|
{tab === 'atoms' ? (
|
|
46
46
|
<FrontCoreShowcase />
|
|
47
|
-
) : error ? (
|
|
48
|
-
<Text as="pre" variant="body" style={{ color: 'var(--xfc-warning)', margin: 0, whiteSpace: 'pre-wrap' }}>
|
|
49
|
-
{error}
|
|
50
|
-
</Text>
|
|
51
47
|
) : (
|
|
52
48
|
<Text as="pre" variant="small" style={{ margin: 0, overflow: 'auto' }}>
|
|
53
|
-
{
|
|
49
|
+
{data != null ? safeJsonStringify(data, 2) : '—'}
|
|
54
50
|
</Text>
|
|
55
51
|
)}
|
|
56
52
|
</Stack>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Axios — baseURL, 로깅 인터셉터, 전역 훅(onRequestStart/End), Bearer
|
|
3
|
+
* HTTP 200/201 본문이 `{ code, data, error }` 이면 code 검사 후 data 로 치환 또는 토스트+reject
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import axios, {
|
|
@@ -8,25 +9,43 @@ import axios, {
|
|
|
8
9
|
type InternalAxiosRequestConfig,
|
|
9
10
|
} from 'axios';
|
|
10
11
|
import { sessionStore } from '../store/session-store';
|
|
12
|
+
import {
|
|
13
|
+
applyCommonResponseEnvelope,
|
|
14
|
+
CommonResponseRejectedError,
|
|
15
|
+
formatCommonErrorField,
|
|
16
|
+
isCommonResponsePayload,
|
|
17
|
+
} from './commonResponse';
|
|
11
18
|
|
|
12
19
|
const LOG_PREFIX = '[xframe/http]';
|
|
13
20
|
|
|
14
21
|
export type OnRequestStart = () => void;
|
|
15
22
|
export type OnRequestEnd = () => void;
|
|
16
23
|
export type OnError = (message: string, code?: string | number) => void;
|
|
24
|
+
export type OnCommonBusinessError = (message: string, appCode: number) => void;
|
|
17
25
|
|
|
18
26
|
let onRequestStart: OnRequestStart = () => {};
|
|
19
27
|
let onRequestEnd: OnRequestEnd = () => {};
|
|
20
28
|
let onError: OnError = () => {};
|
|
29
|
+
let onCommonBusinessError: OnCommonBusinessError = () => {};
|
|
30
|
+
|
|
31
|
+
export { CommonResponseRejectedError } from './commonResponse';
|
|
32
|
+
export {
|
|
33
|
+
COMMON_RESPONSE_SUCCESS_CODE,
|
|
34
|
+
formatCommonErrorField,
|
|
35
|
+
isCommonResponsePayload,
|
|
36
|
+
} from './commonResponse';
|
|
21
37
|
|
|
22
38
|
export function setApiHooks(hooks: {
|
|
23
39
|
onRequestStart?: OnRequestStart;
|
|
24
40
|
onRequestEnd?: OnRequestEnd;
|
|
25
41
|
onError?: OnError;
|
|
42
|
+
/** HTTP 정상(2xx)인데 body.code !== 0 일 때 (토스트 등) */
|
|
43
|
+
onCommonBusinessError?: OnCommonBusinessError;
|
|
26
44
|
}) {
|
|
27
45
|
if (hooks.onRequestStart) onRequestStart = hooks.onRequestStart;
|
|
28
46
|
if (hooks.onRequestEnd) onRequestEnd = hooks.onRequestEnd;
|
|
29
47
|
if (hooks.onError) onError = hooks.onError;
|
|
48
|
+
if (hooks.onCommonBusinessError) onCommonBusinessError = hooks.onCommonBusinessError;
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
export const apiClient = axios.create({
|
|
@@ -56,11 +75,13 @@ function logRequest(config: InternalAxiosRequestConfig) {
|
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
function logResponseSuccess(response: AxiosResponse) {
|
|
78
|
+
const body = response.data;
|
|
79
|
+
const data = isCommonResponsePayload(body) ? body.data : body;
|
|
59
80
|
console.log(`${LOG_PREFIX} response`, {
|
|
60
81
|
status: response.status,
|
|
61
82
|
statusText: response.statusText,
|
|
62
83
|
url: response.config.url,
|
|
63
|
-
data
|
|
84
|
+
data,
|
|
64
85
|
});
|
|
65
86
|
}
|
|
66
87
|
|
|
@@ -104,28 +125,37 @@ apiClient.interceptors.response.use(
|
|
|
104
125
|
const c = response.config as ExtConfig;
|
|
105
126
|
if (!c.skipGlobalIndicator) onRequestEnd();
|
|
106
127
|
logResponseSuccess(response);
|
|
128
|
+
try {
|
|
129
|
+
applyCommonResponseEnvelope(response, onCommonBusinessError);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return Promise.reject(e);
|
|
132
|
+
}
|
|
107
133
|
return response;
|
|
108
134
|
},
|
|
109
135
|
(err: AxiosError) => {
|
|
110
136
|
const c = err.config as ExtConfig | undefined;
|
|
111
137
|
if (!c?.skipGlobalIndicator) onRequestEnd();
|
|
112
138
|
logResponseError(err);
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
const resData = err.response?.data;
|
|
140
|
+
let raw: unknown =
|
|
141
|
+
isCommonResponsePayload(resData) ? resData.error : undefined;
|
|
142
|
+
if (raw === undefined) {
|
|
143
|
+
raw =
|
|
144
|
+
(err.response?.data as { message?: unknown; error?: unknown } | undefined)?.message ??
|
|
145
|
+
(err.response?.data as { message?: unknown; error?: unknown } | undefined)?.error ??
|
|
146
|
+
(err as Error)?.message ??
|
|
147
|
+
'Request failed';
|
|
148
|
+
}
|
|
117
149
|
const message =
|
|
118
150
|
typeof raw === 'string'
|
|
119
151
|
? raw
|
|
120
152
|
: Array.isArray(raw)
|
|
121
153
|
? raw.map((x) => (typeof x === 'string' ? x : String(x))).join(', ')
|
|
122
|
-
:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
: JSON.stringify(raw);
|
|
128
|
-
const code = res?.status ?? (res?.data?.code as string | number | undefined);
|
|
154
|
+
: formatCommonErrorField(raw);
|
|
155
|
+
const code =
|
|
156
|
+
err.response?.status ??
|
|
157
|
+
(isCommonResponsePayload(resData) ? resData.code : undefined) ??
|
|
158
|
+
(err.response?.data as { code?: string | number } | undefined)?.code;
|
|
129
159
|
onError(message, code);
|
|
130
160
|
return Promise.reject(err);
|
|
131
161
|
},
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 서버 공통 본문: HTTP 200/201 + body `{ code, data, error }`
|
|
3
|
+
* — 비즈니스 실패는 code !== 0 (HTTP는 정상).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AxiosResponse } from 'axios';
|
|
7
|
+
|
|
8
|
+
/** 백엔드와 동일한 “성공” 코드 */
|
|
9
|
+
export const COMMON_RESPONSE_SUCCESS_CODE = 0 as const;
|
|
10
|
+
|
|
11
|
+
export class CommonResponseRejectedError extends Error {
|
|
12
|
+
readonly appCode: number;
|
|
13
|
+
readonly payload: unknown;
|
|
14
|
+
|
|
15
|
+
constructor(message: string, appCode: number, payload: unknown) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'CommonResponseRejectedError';
|
|
18
|
+
this.appCode = appCode;
|
|
19
|
+
this.payload = payload;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isCommonResponsePayload(
|
|
24
|
+
body: unknown,
|
|
25
|
+
): body is { code: number; data: unknown; error?: unknown } {
|
|
26
|
+
if (body === null || typeof body !== 'object') return false;
|
|
27
|
+
const b = body as Record<string, unknown>;
|
|
28
|
+
return typeof b.code === 'number' && 'data' in b;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractMessageString(value: unknown): string | null {
|
|
32
|
+
if (value == null) return null;
|
|
33
|
+
if (typeof value === 'string') return value;
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
const first = value.find((x) => typeof x === 'string');
|
|
36
|
+
return first ?? (value.map((x) => (typeof x === 'string' ? x : String(x))).join(', ') || null);
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'object' && 'message' in value) {
|
|
39
|
+
const inner = (value as { message: unknown }).message;
|
|
40
|
+
return extractMessageString(inner);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `error` 필드를 사용자용 문구로 */
|
|
46
|
+
export function formatCommonErrorField(error: unknown): string {
|
|
47
|
+
const extracted = extractMessageString(error);
|
|
48
|
+
if (extracted) return extracted;
|
|
49
|
+
if (typeof error === 'string') return error;
|
|
50
|
+
if (error != null && typeof error === 'object') {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(error);
|
|
53
|
+
} catch {
|
|
54
|
+
return 'Request failed';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return 'Request failed';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type OnCommonBusinessError = (message: string, appCode: number) => void;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 성공 인터셉터에서 호출: 공통 래핑이면 code 검사 후 data 로 치환하거나 reject.
|
|
64
|
+
*/
|
|
65
|
+
export function applyCommonResponseEnvelope(
|
|
66
|
+
response: AxiosResponse,
|
|
67
|
+
onBusinessError: OnCommonBusinessError,
|
|
68
|
+
): unknown {
|
|
69
|
+
const body = response.data;
|
|
70
|
+
if (!isCommonResponsePayload(body)) {
|
|
71
|
+
return response;
|
|
72
|
+
}
|
|
73
|
+
if (body.code !== COMMON_RESPONSE_SUCCESS_CODE) {
|
|
74
|
+
const msg = formatCommonErrorField(body.error);
|
|
75
|
+
onBusinessError(msg, body.code);
|
|
76
|
+
throw new CommonResponseRejectedError(msg, body.code, body);
|
|
77
|
+
}
|
|
78
|
+
response.data = body.data;
|
|
79
|
+
return response;
|
|
80
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HealthData } from '../../types/api';
|
|
2
2
|
import type { ApiMethodConfig, ApiMethodStore } from '../types';
|
|
3
3
|
import { healthEndpoint } from '@shared/endpoint';
|
|
4
4
|
|
|
5
5
|
/** GET /health — params·body 없음, 스토어는 validate/post 에서 사용 가능 */
|
|
6
|
-
export const healthMethod: ApiMethodConfig<void,
|
|
6
|
+
export const healthMethod: ApiMethodConfig<void, HealthData, ApiMethodStore> = {
|
|
7
7
|
schema: {},
|
|
8
8
|
endpoint: healthEndpoint,
|
|
9
9
|
ui: {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RootStore 컨텍스트 — useStore / useParamStore / useSendMessage 등
|
|
3
3
|
* (싱글톤 rootStore 이므로 Provider 없이도 rootStore 직접 import 가능)
|
|
4
|
+
*
|
|
5
|
+
* 최상위 레이어: HTTP 로딩 바(`indicator`) · `rootStore.toasts` → ToastList
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import React, { createContext, useCallback, useContext } from 'react';
|
|
9
|
+
import { ToastList } from '@xfilecom/front-core';
|
|
10
|
+
import { observer } from 'mobx-react-lite';
|
|
7
11
|
import type { EndpointDef } from '@shared/endpoint';
|
|
8
12
|
import type { SendMessageOptions } from '../types/ui';
|
|
9
13
|
import { rootStore } from '../store/root-store';
|
|
@@ -12,6 +16,34 @@ type RootStoreApi = typeof rootStore;
|
|
|
12
16
|
|
|
13
17
|
const StoreContext = createContext<RootStoreApi | null>(null);
|
|
14
18
|
|
|
19
|
+
const GlobalHttpIndicatorView = observer(function GlobalHttpIndicatorView() {
|
|
20
|
+
if (!rootStore.indicator.loading) return null;
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className="xfc-global-http-indicator"
|
|
24
|
+
role="progressbar"
|
|
25
|
+
aria-busy="true"
|
|
26
|
+
aria-label="Loading"
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const GlobalToastStackView = observer(function GlobalToastStackView() {
|
|
32
|
+
const entries = rootStore.toasts.map((t) => ({
|
|
33
|
+
id: t.id,
|
|
34
|
+
severity: t.severity,
|
|
35
|
+
message: t.message,
|
|
36
|
+
}));
|
|
37
|
+
return (
|
|
38
|
+
<ToastList
|
|
39
|
+
toasts={entries}
|
|
40
|
+
onDismiss={(id) => {
|
|
41
|
+
rootStore.removeToast(id);
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
15
47
|
export function useStore(): RootStoreApi {
|
|
16
48
|
const ctx = useContext(StoreContext);
|
|
17
49
|
if (!ctx) throw new Error('StoreProvider is required');
|
|
@@ -19,7 +51,13 @@ export function useStore(): RootStoreApi {
|
|
|
19
51
|
}
|
|
20
52
|
|
|
21
53
|
export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
22
|
-
return
|
|
54
|
+
return (
|
|
55
|
+
<StoreContext.Provider value={rootStore}>
|
|
56
|
+
<GlobalHttpIndicatorView />
|
|
57
|
+
<GlobalToastStackView />
|
|
58
|
+
{children}
|
|
59
|
+
</StoreContext.Provider>
|
|
60
|
+
);
|
|
23
61
|
}
|
|
24
62
|
|
|
25
63
|
export function useSendMessage() {
|
|
@@ -5,6 +5,7 @@ import { rootStore } from '../store/root-store';
|
|
|
5
5
|
/**
|
|
6
6
|
* Zustand 스타일 셀렉터 — RootStore(MobX) 구독
|
|
7
7
|
* 예: `useAppStore((s) => s.lastHealthAt)`, `useAppStore((s) => s.setLastHealthAt)`
|
|
8
|
+
* (프로토타입 메서드는 분리 시 this 가 깨지므로, 콜백으로 쓸 메서드는 스토어에서 화살표 필드로 두거나 `bind` 하세요.)
|
|
8
9
|
*/
|
|
9
10
|
export function useAppStore<T>(selector: (store: typeof rootStore) => T): T {
|
|
10
11
|
return useSyncExternalStore(
|
|
@@ -1,38 +1,34 @@
|
|
|
1
|
-
import { useEffect
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
2
|
import { configureApi } from '../api/client';
|
|
3
3
|
import { fetchHealth } from '../methods/health';
|
|
4
|
-
import {
|
|
4
|
+
import { rootStore } from '../store/root-store';
|
|
5
5
|
import { useAppStore } from './useAppStore';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @param apiBaseUrl 선택 — 넘기면 `configureApi` 로 baseURL 갱신. 앱 entry 에서 이미 `configureApi` 했다면 생략 가능.
|
|
9
|
+
* `data`·`error` 는 `rootStore` 에 저장·`runInAction`; 로딩 표시는 `StoreProvider` 전역 바 + `indicator`(axios).
|
|
9
10
|
*/
|
|
10
11
|
export function useHealthStatus(apiBaseUrl?: string) {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const [error, setError] = useState<string | null>(null);
|
|
12
|
+
const data = useAppStore((s) => s.healthData);
|
|
13
|
+
const error = useAppStore((s) => s.healthError);
|
|
14
14
|
|
|
15
15
|
useEffect(() => {
|
|
16
16
|
if (apiBaseUrl != null && apiBaseUrl !== '') {
|
|
17
17
|
configureApi({ baseURL: apiBaseUrl });
|
|
18
18
|
}
|
|
19
19
|
let cancelled = false;
|
|
20
|
-
|
|
21
|
-
setText('loading…');
|
|
20
|
+
rootStore.applyHealthFetchStart();
|
|
22
21
|
fetchHealth()
|
|
23
22
|
.then((body) => {
|
|
24
|
-
if (!cancelled)
|
|
25
|
-
setText(safeJsonStringify(body, 2));
|
|
26
|
-
setLastHealthAt(Date.now());
|
|
27
|
-
}
|
|
23
|
+
if (!cancelled) rootStore.applyHealthFetchSuccess(body);
|
|
28
24
|
})
|
|
29
|
-
.catch((e:
|
|
30
|
-
if (!cancelled)
|
|
25
|
+
.catch((e: unknown) => {
|
|
26
|
+
if (!cancelled) rootStore.applyHealthFetchFailure(e);
|
|
31
27
|
});
|
|
32
28
|
return () => {
|
|
33
29
|
cancelled = true;
|
|
34
30
|
};
|
|
35
|
-
}, [apiBaseUrl
|
|
31
|
+
}, [apiBaseUrl]);
|
|
36
32
|
|
|
37
|
-
return {
|
|
33
|
+
return { data, error };
|
|
38
34
|
}
|
|
@@ -10,7 +10,16 @@ export {
|
|
|
10
10
|
useSessionStore,
|
|
11
11
|
} from './context/StoreProvider';
|
|
12
12
|
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
configureApi,
|
|
15
|
+
getApiBaseUrl,
|
|
16
|
+
setApiHooks,
|
|
17
|
+
apiClient,
|
|
18
|
+
CommonResponseRejectedError,
|
|
19
|
+
COMMON_RESPONSE_SUCCESS_CODE,
|
|
20
|
+
formatCommonErrorField,
|
|
21
|
+
isCommonResponsePayload,
|
|
22
|
+
} from './api/client';
|
|
14
23
|
export { sendRequest, type RequestTransportOptions } from './api/request';
|
|
15
24
|
export type { EndpointDef, HttpMethod } from '@shared/endpoint';
|
|
16
25
|
export { healthEndpoint, appMetaEndpoint, unwrapResponseData } from '@shared/endpoint';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { sendRequest } from '../api/request';
|
|
2
2
|
import { healthEndpoint } from '@shared/endpoint';
|
|
3
|
-
import type {
|
|
3
|
+
import type { HealthData } from '../types/api';
|
|
4
4
|
|
|
5
5
|
/** 직접 호출용 (토스트 없음) — UI 훅은 보통 이 함수 사용 */
|
|
6
|
-
export async function fetchHealth(): Promise<
|
|
7
|
-
return sendRequest<unknown,
|
|
6
|
+
export async function fetchHealth(): Promise<HealthData> {
|
|
7
|
+
return sendRequest<unknown, HealthData>(healthEndpoint, {});
|
|
8
8
|
}
|
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
* Root store — session, param, cache, indicator, execute/sendMessage (business-promotion 패턴 축소판)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import axios from 'axios';
|
|
5
6
|
import { makeAutoObservable, runInAction } from 'mobx';
|
|
6
7
|
import { setApiHooks } from '../api/client';
|
|
8
|
+
import {
|
|
9
|
+
CommonResponseRejectedError,
|
|
10
|
+
formatCommonErrorField,
|
|
11
|
+
isCommonResponsePayload,
|
|
12
|
+
} from '../api/commonResponse';
|
|
7
13
|
import { sendRequest } from '../api/request';
|
|
8
14
|
import type {
|
|
9
15
|
ApiMethodConfig,
|
|
@@ -23,6 +29,7 @@ import { sessionStore } from './session-store';
|
|
|
23
29
|
import { ParamStore } from '../params/param-store';
|
|
24
30
|
import type { ApiErrorItem, IndicatorState, SendMessageOptions, ToastItem } from '../types/ui';
|
|
25
31
|
import type { ToastSeverity } from '../types/ui';
|
|
32
|
+
import type { HealthData } from '../types/api';
|
|
26
33
|
|
|
27
34
|
function genId() {
|
|
28
35
|
return `id_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
@@ -53,8 +60,14 @@ function extractMessageString(value: unknown): string | null {
|
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
function getErrorMessage(e: unknown): string {
|
|
56
|
-
|
|
57
|
-
const
|
|
63
|
+
if (e instanceof CommonResponseRejectedError) return e.message;
|
|
64
|
+
const err = e as { response?: { data?: unknown }; message?: string };
|
|
65
|
+
const resData = err?.response?.data;
|
|
66
|
+
if (isCommonResponsePayload(resData)) {
|
|
67
|
+
return formatCommonErrorField(resData.error);
|
|
68
|
+
}
|
|
69
|
+
const d = resData as { message?: unknown; error?: unknown } | undefined;
|
|
70
|
+
const raw = d?.message ?? d?.error ?? err?.message;
|
|
58
71
|
const extracted = extractMessageString(raw);
|
|
59
72
|
if (extracted) return extracted;
|
|
60
73
|
if (raw != null && typeof raw === 'string') return raw;
|
|
@@ -77,6 +90,10 @@ export class RootStore {
|
|
|
77
90
|
/** 데모·useHealthStatus 용 타임스탬프 */
|
|
78
91
|
lastHealthAt: number | null = null;
|
|
79
92
|
|
|
93
|
+
/** `/health` 성공 본문 (axios 공통 래핑 제거 후) */
|
|
94
|
+
healthData: HealthData | null = null;
|
|
95
|
+
healthError: string | null = null;
|
|
96
|
+
|
|
80
97
|
constructor() {
|
|
81
98
|
makeAutoObservable(this);
|
|
82
99
|
this.sessionStore.registerLogoutCallback(() => {
|
|
@@ -93,7 +110,12 @@ export class RootStore {
|
|
|
93
110
|
if (this._multiEndpointBatch) return;
|
|
94
111
|
runInAction(() => this._endRequest());
|
|
95
112
|
},
|
|
96
|
-
onError: () => {
|
|
113
|
+
onError: (message) => {
|
|
114
|
+
runInAction(() => this.addToast(message, 'error'));
|
|
115
|
+
},
|
|
116
|
+
onCommonBusinessError: (message) => {
|
|
117
|
+
runInAction(() => this.addToast(message, 'error'));
|
|
118
|
+
},
|
|
97
119
|
});
|
|
98
120
|
}
|
|
99
121
|
|
|
@@ -109,9 +131,31 @@ export class RootStore {
|
|
|
109
131
|
return this._toasts;
|
|
110
132
|
}
|
|
111
133
|
|
|
112
|
-
|
|
134
|
+
/** useAppStore 셀렉터로 넘길 때 this 유지 */
|
|
135
|
+
setLastHealthAt = (t: number) => {
|
|
113
136
|
this.lastHealthAt = t;
|
|
114
|
-
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** 로딩 표시는 `indicator`(axios 훅) — 여기선 에러만 초기화 */
|
|
140
|
+
applyHealthFetchStart = () => {
|
|
141
|
+
runInAction(() => {
|
|
142
|
+
this.healthError = null;
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
applyHealthFetchSuccess = (data: HealthData) => {
|
|
147
|
+
runInAction(() => {
|
|
148
|
+
this.healthError = null;
|
|
149
|
+
this.healthData = data;
|
|
150
|
+
this.lastHealthAt = Date.now();
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
applyHealthFetchFailure = (err: unknown) => {
|
|
155
|
+
runInAction(() => {
|
|
156
|
+
this.healthError = getErrorMessage(err);
|
|
157
|
+
});
|
|
158
|
+
};
|
|
115
159
|
|
|
116
160
|
private _startRequest() {
|
|
117
161
|
this._requestDepth++;
|
|
@@ -225,7 +269,11 @@ export class RootStore {
|
|
|
225
269
|
}
|
|
226
270
|
return result;
|
|
227
271
|
} catch (e) {
|
|
228
|
-
if (
|
|
272
|
+
if (
|
|
273
|
+
showErrorToast &&
|
|
274
|
+
!(e instanceof CommonResponseRejectedError) &&
|
|
275
|
+
!axios.isAxiosError(e)
|
|
276
|
+
) {
|
|
229
277
|
runInAction(() => this.addToast(getErrorMessage(e), 'error'));
|
|
230
278
|
}
|
|
231
279
|
throw e;
|
|
@@ -344,7 +392,13 @@ export class RootStore {
|
|
|
344
392
|
return data;
|
|
345
393
|
} catch (e) {
|
|
346
394
|
runInAction(() => {
|
|
347
|
-
if (
|
|
395
|
+
if (
|
|
396
|
+
showErrorToast &&
|
|
397
|
+
!(e instanceof CommonResponseRejectedError) &&
|
|
398
|
+
!axios.isAxiosError(e)
|
|
399
|
+
) {
|
|
400
|
+
this.addToast(getErrorMessage(e), 'error');
|
|
401
|
+
}
|
|
348
402
|
});
|
|
349
403
|
throw e;
|
|
350
404
|
} finally {
|
|
@@ -67,4 +67,27 @@
|
|
|
67
67
|
border-bottom: 1px solid var(--xfc-border-header);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/* apiClient 진행 중 — StoreProvider 가 `rootStore.indicator.loading` 과 연동 */
|
|
71
|
+
.xfc-global-http-indicator {
|
|
72
|
+
position: fixed;
|
|
73
|
+
top: 0;
|
|
74
|
+
left: 0;
|
|
75
|
+
right: 0;
|
|
76
|
+
height: 3px;
|
|
77
|
+
z-index: 99999;
|
|
78
|
+
background: var(--xfc-accent, var(--xfc-border));
|
|
79
|
+
pointer-events: none;
|
|
80
|
+
animation: xfc-global-http-pulse 0.9s ease-in-out infinite;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@keyframes xfc-global-http-pulse {
|
|
84
|
+
0%,
|
|
85
|
+
100% {
|
|
86
|
+
opacity: 1;
|
|
87
|
+
}
|
|
88
|
+
50% {
|
|
89
|
+
opacity: 0.45;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
/* —— front-core atoms 추가 오버라이드는 xfc-theme.css 또는 여기 (app이 마지막 로드) —— */
|