Files
Gnuboard7/resources/js/core/template-engine/ErrorDisplay.ts
T
2026-04-01 10:30:52 +09:00

293 lines
10 KiB
TypeScript

import type { TranslationContext } from './TranslationEngine';
import { TemplateEngineError } from './TemplateEngineError';
import { createLogger } from '../utils/Logger';
const logger = createLogger('ErrorDisplay');
/**
* 에러 표시 옵션
*/
export interface ErrorDisplayOptions {
/** 에러 제목 */
title: string;
/** 사용자 메시지 */
message: string;
/** 상세 메시지 (API 응답 메시지 등) */
detailMessage?: string;
/** Font Awesome 아이콘 클래스 */
icon: string;
/** Stack Trace 표시 여부 */
showStack: boolean;
/** Stack Trace 문자열 */
stackTrace?: string;
/** 새로고침 버튼 표시 여부 */
showReloadButton: boolean;
/** 디버그 모드 여부 */
debug: boolean;
}
/**
* 에러 화면 렌더링 컴포넌트
*
* 주의: 이 컴포넌트는 템플릿 엔진 초기화 실패 시에도 사용되므로
* Tailwind CSS 클래스 대신 인라인 스타일을 사용합니다.
* 초기화 실패 시점에는 CSS가 아직 로드되지 않았을 수 있기 때문입니다.
*/
export class ErrorDisplay {
/**
* 에러 화면을 렌더링합니다.
*
* @param containerId 컨테이너 요소 ID
* @param options 에러 표시 옵션
*/
static render(containerId: string, options: ErrorDisplayOptions): void {
const container = document.getElementById(containerId);
if (!container) {
logger.error(`Container #${containerId} not found`);
return;
}
// 스택 트레이스 표시 여부 결정
const shouldShowStack =
options.debug && options.showStack && options.stackTrace;
// 다크 모드 감지
const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
// 인라인 스타일 정의 (CSS 의존성 없이 동작)
const styles = {
container: `
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: ${isDarkMode ? '#111827' : '#f9fafb'};
padding: 1rem;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
`,
card: `
max-width: 28rem;
width: 100%;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 2rem;
text-align: center;
`,
iconContainer: `
margin-bottom: 1.5rem;
`,
icon: `
font-size: 4rem;
color: #ef4444;
`,
title: `
font-size: 1.5rem;
font-weight: 700;
color: ${isDarkMode ? '#ffffff' : '#111827'};
margin-bottom: 1rem;
`,
message: `
color: ${isDarkMode ? '#d1d5db' : '#4b5563'};
margin-bottom: 0.5rem;
line-height: 1.625;
`,
detailMessage: `
font-size: 0.875rem;
color: ${isDarkMode ? '#9ca3af' : '#6b7280'};
margin-bottom: 1.5rem;
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
`,
spacer: `
margin-bottom: 1.5rem;
`,
stackContainer: `
margin-top: 1.5rem;
padding: 1rem;
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
border-radius: 0.375rem;
text-align: left;
`,
stackTitle: `
font-size: 0.875rem;
font-weight: 600;
color: ${isDarkMode ? '#e5e7eb' : '#374151'};
margin-bottom: 0.5rem;
`,
stackTrace: `
font-size: 0.75rem;
color: ${isDarkMode ? '#d1d5db' : '#4b5563'};
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
`,
buttonContainer: `
margin-top: 1.5rem;
`,
button: `
display: inline-flex;
align-items: center;
padding: 0.75rem 1.5rem;
background-color: #2563eb;
color: #ffffff;
font-weight: 500;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
`,
};
container.innerHTML = `
<div style="${styles.container}">
<div style="${styles.card}">
<!-- 아이콘 -->
<div style="${styles.iconContainer}">
<i class="fa-solid ${options.icon}" style="${styles.icon}"></i>
</div>
<!-- 제목 -->
<h1 style="${styles.title}">
${this.escapeHtml(options.title)}
</h1>
<!-- 사용자 메시지 -->
<p style="${styles.message}">
${this.escapeHtml(options.message)}
</p>
<!-- 상세 메시지 (API 응답 등) -->
${
options.detailMessage
? `
<p style="${styles.detailMessage}">
${this.escapeHtml(options.detailMessage)}
</p>
`
: `<div style="${styles.spacer}"></div>`
}
<!-- Stack Trace (디버그 모드일 때만) -->
${
shouldShowStack
? `
<div style="${styles.stackContainer}">
<p style="${styles.stackTitle}">
Stack Trace:
</p>
<pre style="${styles.stackTrace}">${this.escapeHtml(
options.stackTrace
)}</pre>
</div>
`
: ''
}
<!-- 새로고침 버튼 (복구 가능할 때만) -->
${
options.showReloadButton
? `
<div style="${styles.buttonContainer}">
<button
onclick="window.location.reload()"
style="${styles.button}"
onmouseover="this.style.backgroundColor='#1d4ed8'"
onmouseout="this.style.backgroundColor='#2563eb'"
>
<i class="fa-solid fa-rotate-right" style="margin-right: 0.5rem;"></i>
새로고침
</button>
</div>
`
: ''
}
</div>
</div>
`;
}
/**
* TemplateEngineError 객체로부터 에러 화면을 렌더링합니다.
*
* @param containerId 컨테이너 요소 ID
* @param error 템플릿 엔진 에러 객체
* @param title 에러 제목
* @param debug 디버그 모드 여부
* @param context 번역 컨텍스트 (선택)
* @param translationEngine 번역 엔진 (선택)
* @param originalError 원본 에러 객체 (선택, 상세 메시지 추출용)
*/
static renderFromError(
containerId: string,
error: TemplateEngineError,
title: string,
debug: boolean = false,
context?: TranslationContext,
translationEngine?: {
translate: (key: string, context: TranslationContext) => string;
},
originalError?: Error
): void {
// 다국어 메시지 조회
let message: string;
let detailMessage: string | undefined;
// userMessageKey가 $t: 로 시작하면 번역 시도
if (error.userMessageKey.startsWith('$t:')) {
if (translationEngine && context) {
try {
message = translationEngine.translate(error.userMessageKey, context);
} catch (e) {
// 번역 실패 시 키에서 $t: 제거하여 표시
message = error.userMessageKey.replace('$t:', '');
}
} else {
// 번역 엔진이 없으면 키에서 $t: 제거하여 표시
message = error.userMessageKey.replace('$t:', '');
}
} else {
// 일반 에러 메시지는 그대로 사용
message = error.userMessageKey;
}
// 원본 에러에서 상세 메시지 추출 (LayoutLoaderError 등)
if (originalError) {
const anyError = originalError as any;
// LayoutLoaderError의 details.apiMessage 추출
if (anyError.details?.apiMessage) {
detailMessage = anyError.details.apiMessage;
}
// Axios 에러의 response.data.message 추출
else if (anyError.response?.data?.message) {
detailMessage = anyError.response.data.message;
}
}
this.render(containerId, {
title,
message,
detailMessage,
icon: error.icon,
showStack: error.showStack,
stackTrace: error.stack,
showReloadButton: error.recoverable,
debug,
});
}
/**
* HTML 이스케이프 처리
*
* @param text 이스케이프할 텍스트
*/
private static escapeHtml(text: string | undefined): string {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}