[251022] feat: VoiceDebugDashboard
🕐 커밋 시간: 2025. 10. 22. 15:14:24 📊 변경 통계: • 총 파일: 3개 • 추가: +440줄 • 삭제: -1줄 📁 추가된 파일: + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceDebugDashboard.jsx 📝 수정된 파일: ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript): 🔄 Modified: clearAllTimers() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less (unknown): ✅ Added: scale(), rotate(), media(), translateY(), repeat() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceDebugDashboard.jsx (javascript): ✅ Added: getEventIcon()
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
// src/views/SearchPanel/VoiceInputOverlay/VoiceDebugDashboard.jsx
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import css from './VoiceInputOverlay.module.less';
|
||||
|
||||
/**
|
||||
* VoiceDebugDashboard - 음성 인식 전체 디버깅 대시보드
|
||||
* 풀화면 오버레이로 표시되며, 다음 정보를 제공:
|
||||
* 1. 현재 상태 (Mode, isListening, Countdown, SearchId)
|
||||
* 2. 이벤트 타임라인 (최근 20개 이벤트)
|
||||
* 3. API 요청 정보
|
||||
* 4. 에러 정보 (있는 경우)
|
||||
*/
|
||||
const VoiceDebugDashboard = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
currentMode,
|
||||
isListening,
|
||||
countdown,
|
||||
shopperHouseSearchId,
|
||||
webSpeechEventLogs,
|
||||
lastSTTText,
|
||||
sttResponseText,
|
||||
interimText,
|
||||
errorMessage,
|
||||
voiceInputMode,
|
||||
isBubbleClickSearch,
|
||||
}) => {
|
||||
// 이벤트 로그를 시간순으로 정렬 (최신이 위)
|
||||
// Note: Hooks must be called at the top level, before any early returns
|
||||
const sortedLogs = useMemo(() => {
|
||||
return [...webSpeechEventLogs].reverse();
|
||||
}, [webSpeechEventLogs]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
// 이벤트 타입별 아이콘
|
||||
const getEventIcon = (event) => {
|
||||
const icons = {
|
||||
INIT: '🔧',
|
||||
START: '▶️',
|
||||
RESULT: '🎤',
|
||||
RESULT_FINAL: '✅',
|
||||
ERROR: '❌',
|
||||
END: '⏹️',
|
||||
STOP: '🛑',
|
||||
ABORT: '💥',
|
||||
RESTART: '🔄',
|
||||
TIMEOUT_15S: '⏰',
|
||||
SILENCE_3S: '🔇',
|
||||
MODE_CHANGE: '🔀',
|
||||
API_REQUEST: '📤',
|
||||
API_RESPONSE: '📥',
|
||||
BUBBLE_CLICK: '💡',
|
||||
OVERLAY_OPEN: '👁️',
|
||||
OVERLAY_CLOSE: '🙈',
|
||||
};
|
||||
return icons[event] || '•';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css.voiceDebugDashboardOverlay} onClick={onClose}>
|
||||
<div className={css.voiceDebugDashboard} onClick={(e) => e.stopPropagation()}>
|
||||
{/* 헤더 */}
|
||||
<div className={css.dashboardHeader}>
|
||||
<div className={css.dashboardTitle}>🎯 Voice Input Debug Dashboard</div>
|
||||
<button className={css.dashboardCloseBtn} onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className={css.dashboardContent}>
|
||||
{/* 1️⃣ 현재 상태 섹션 */}
|
||||
<section className={css.dashboardSection}>
|
||||
<h3 className={css.sectionTitle}>📍 Current State</h3>
|
||||
<div className={css.stateGrid}>
|
||||
<div className={css.stateItem}>
|
||||
<span className={css.stateLabel}>Mode:</span>
|
||||
<span className={css.stateValue}>{currentMode}</span>
|
||||
</div>
|
||||
<div className={css.stateItem}>
|
||||
<span className={css.stateLabel}>isListening:</span>
|
||||
<span className={`${css.stateValue} ${isListening ? css.active : ''}`}>
|
||||
{isListening ? '🎤 YES' : '❌ NO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.stateItem}>
|
||||
<span className={css.stateLabel}>VoiceInputMode:</span>
|
||||
<span className={css.stateValue}>{voiceInputMode || 'None'}</span>
|
||||
</div>
|
||||
<div className={css.stateItem}>
|
||||
<span className={css.stateLabel}>Countdown:</span>
|
||||
<span className={css.stateValue}>{countdown}s</span>
|
||||
</div>
|
||||
<div className={css.stateItem}>
|
||||
<span className={css.stateLabel}>SearchId:</span>
|
||||
<span className={css.stateValue} title={shopperHouseSearchId || 'N/A'}>
|
||||
{shopperHouseSearchId
|
||||
? shopperHouseSearchId.substring(0, 12) + '...'
|
||||
: 'None (첫 번째 발화)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.stateItem}>
|
||||
<span className={css.stateLabel}>BubbleSearch:</span>
|
||||
<span className={css.stateValue}>{isBubbleClickSearch ? '✅ YES' : '❌ NO'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 2️⃣ Interim/Final Text 섹션 */}
|
||||
<section className={css.dashboardSection}>
|
||||
<h3 className={css.sectionTitle}>📝 Text Input</h3>
|
||||
<div className={css.textBlock}>
|
||||
<div className={css.textItem}>
|
||||
<span className={css.textLabel}>Interim Text:</span>
|
||||
<div className={css.textContent}>{interimText || '(empty)'}</div>
|
||||
</div>
|
||||
<div className={css.textItem}>
|
||||
<span className={css.textLabel}>STT Response Text:</span>
|
||||
<div className={css.textContent}>{sttResponseText || '(empty)'}</div>
|
||||
</div>
|
||||
<div className={css.textItem}>
|
||||
<span className={css.textLabel}>Last STT Text (Redux):</span>
|
||||
<div className={css.textContent}>{lastSTTText || '(empty)'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3️⃣ 에러 정보 */}
|
||||
{errorMessage && (
|
||||
<section className={css.dashboardSection}>
|
||||
<h3 className={css.sectionTitle}>🔴 Error Message</h3>
|
||||
<div className={css.errorBlock}>{errorMessage}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 4️⃣ 이벤트 타임라인 섹션 */}
|
||||
<section className={css.dashboardSection}>
|
||||
<h3 className={css.sectionTitle}>📋 Event Timeline ({sortedLogs.length} events)</h3>
|
||||
<div className={css.timelineContainer}>
|
||||
{sortedLogs.length === 0 ? (
|
||||
<div className={css.noEvents}>No events yet...</div>
|
||||
) : (
|
||||
<div className={css.timelineList}>
|
||||
{sortedLogs.map((log, idx) => (
|
||||
<div key={log.id} className={`${css.timelineItem} ${css[log.type]}`}>
|
||||
<div className={css.timelineIndex}>{sortedLogs.length - idx}</div>
|
||||
<div className={css.timelineIcon}>{getEventIcon(log.event)}</div>
|
||||
<div className={css.timelineContent}>
|
||||
<div className={css.timelineEvent}>
|
||||
<span className={css.eventName}>{log.event}</span>
|
||||
<span className={css.eventTime}>[{log.timestamp}]</span>
|
||||
</div>
|
||||
{log.details && <div className={css.timelineDetails}>{log.details}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
VoiceDebugDashboard.propTypes = {
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
currentMode: PropTypes.string.isRequired,
|
||||
isListening: PropTypes.bool.isRequired,
|
||||
countdown: PropTypes.number.isRequired,
|
||||
shopperHouseSearchId: PropTypes.string,
|
||||
webSpeechEventLogs: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
timestamp: PropTypes.string,
|
||||
event: PropTypes.string,
|
||||
details: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
})
|
||||
),
|
||||
lastSTTText: PropTypes.string,
|
||||
sttResponseText: PropTypes.string,
|
||||
interimText: PropTypes.string,
|
||||
errorMessage: PropTypes.string,
|
||||
voiceInputMode: PropTypes.string,
|
||||
isBubbleClickSearch: PropTypes.bool,
|
||||
};
|
||||
|
||||
VoiceDebugDashboard.defaultProps = {
|
||||
shopperHouseSearchId: null,
|
||||
webSpeechEventLogs: [],
|
||||
lastSTTText: '',
|
||||
sttResponseText: '',
|
||||
interimText: '',
|
||||
errorMessage: '',
|
||||
voiceInputMode: null,
|
||||
isBubbleClickSearch: false,
|
||||
};
|
||||
|
||||
export default VoiceDebugDashboard;
|
||||
@@ -23,6 +23,7 @@ import VoicePromptScreen from './modes/VoicePromptScreen';
|
||||
import VoiceResponse from './modes/VoiceResponse';
|
||||
import VoiceApiError from './modes/VoiceApiError';
|
||||
import WebSpeechEventDebug from './WebSpeechEventDebug';
|
||||
import VoiceDebugDashboard from './VoiceDebugDashboard';
|
||||
import css from './VoiceInputOverlay.module.less';
|
||||
|
||||
const OverlayContainer = SpotlightContainerDecorator(
|
||||
@@ -165,6 +166,8 @@ const VoiceInputOverlay = ({
|
||||
const [webSpeechEventLogs, setWebSpeechEventLogs] = useState([]);
|
||||
// Bubble 클릭으로 검색이 시작되었는지 추적하는 상태
|
||||
const [isBubbleClickSearch, setIsBubbleClickSearch] = useState(false);
|
||||
// 디버그 대시보드 표시 여부
|
||||
const [showDashboard, setShowDashboard] = useState(false);
|
||||
// 검색 기록 관리 (localStorage 기반, 최근 5개)
|
||||
const [searchHistory, setSearchHistory] = useState(() => {
|
||||
const history = readLocalStorage(SEARCH_HISTORY_KEY, DEFAULT_SUGGESTIONS);
|
||||
@@ -193,8 +196,10 @@ const VoiceInputOverlay = ({
|
||||
console.log(
|
||||
`📍 [DEBUG][VoiceInputOverlay] Current state: isVisible=true, mode=${currentMode}`
|
||||
);
|
||||
// 모드 변경 이벤트 로깅
|
||||
addWebSpeechEventLog('MODE_CHANGE', `Mode switched to: ${currentMode}`);
|
||||
}
|
||||
}, [currentMode, isVisible]);
|
||||
}, [currentMode, isVisible, addWebSpeechEventLog]);
|
||||
|
||||
// 🔍 검색 기록 저장 함수 (성능 최적화: stable reference)
|
||||
const addToSearchHistory = useCallback((searchText) => {
|
||||
@@ -667,6 +672,7 @@ const VoiceInputOverlay = ({
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
silenceDetectionTimerRef.current = setTimeout(() => {
|
||||
console.log('[VoiceInput] 🔇 3초 동안 입력 없음 - 자동 종료');
|
||||
addWebSpeechEventLog('SILENCE_3S', 'No input detected for 3 seconds - auto finish');
|
||||
processFinalVoiceInput('3초 silence detection');
|
||||
}, 3000); // 3초
|
||||
|
||||
@@ -977,6 +983,12 @@ const VoiceInputOverlay = ({
|
||||
console.log('[VoiceInput] ├─ currentSearchId:', currentSearchId);
|
||||
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
|
||||
|
||||
// API 호출 이벤트 로깅
|
||||
const searchIdInfo = currentSearchId
|
||||
? `(2nd query, searchId: ${currentSearchId.substring(0, 8)}...)`
|
||||
: '(1st query, no searchId)';
|
||||
addWebSpeechEventLog('API_REQUEST', `Query: "${query}" ${searchIdInfo}`);
|
||||
|
||||
dispatch(getShopperHouseSearch(query, currentSearchId));
|
||||
} else {
|
||||
// 입력이 없거나 너무 짧으면 PROMPT 모드로 복귀
|
||||
@@ -1058,6 +1070,13 @@ const VoiceInputOverlay = ({
|
||||
console.log('[VoiceInput] ├─ query:', query);
|
||||
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
|
||||
}
|
||||
|
||||
// API 호출 이벤트 로깅 (Bubble 클릭)
|
||||
const searchIdInfo = currentSearchId
|
||||
? `(2nd query from bubble, searchId: ${currentSearchId.substring(0, 8)}...)`
|
||||
: '(1st query from bubble, no searchId)';
|
||||
addWebSpeechEventLog('BUBBLE_CLICK', `Query: "${query}" ${searchIdInfo}`);
|
||||
|
||||
try {
|
||||
dispatch(getShopperHouseSearch(query, currentSearchId));
|
||||
} catch (error) {
|
||||
@@ -1129,6 +1148,14 @@ const VoiceInputOverlay = ({
|
||||
[handleClose]
|
||||
);
|
||||
|
||||
// 디버그 대시보드 토글 핸들러
|
||||
const handleToggleDashboard = useCallback(() => {
|
||||
setShowDashboard((prev) => !prev);
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔧 [DEBUG] Dashboard toggled:', !showDashboard);
|
||||
}
|
||||
}, [showDashboard]);
|
||||
|
||||
// ⛔ TALK AGAIN 버튼 제거됨 - 더 이상 사용하지 않음
|
||||
// const handleTalkAgain = useCallback(() => { ... }, []);
|
||||
|
||||
@@ -1310,6 +1337,7 @@ const VoiceInputOverlay = ({
|
||||
|
||||
// 15초 타이머 설정: 최대 입력 시간 제한
|
||||
listeningTimerRef.current = setTimeout(() => {
|
||||
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
|
||||
processFinalVoiceInput('15초 타임아웃');
|
||||
}, 15000); // 15초
|
||||
} else {
|
||||
@@ -1393,6 +1421,19 @@ const VoiceInputOverlay = ({
|
||||
handleMicBlur,
|
||||
]);
|
||||
|
||||
// Memoize debug toggle button
|
||||
const debugToggleButton = useMemo(() => {
|
||||
return (
|
||||
<button
|
||||
className={classNames(css.debugToggleButton, showDashboard && css.active)}
|
||||
onClick={handleToggleDashboard}
|
||||
title={showDashboard ? 'Close Debug Dashboard' : 'Open Debug Dashboard'}
|
||||
>
|
||||
<span className={css.debugToggleIcon}>⚙️</span>
|
||||
</button>
|
||||
);
|
||||
}, [showDashboard, handleToggleDashboard]);
|
||||
|
||||
// Memoize debug UI (always render for TV debugging)
|
||||
const debugUI = useMemo(() => {
|
||||
return (
|
||||
@@ -1557,6 +1598,9 @@ const VoiceInputOverlay = ({
|
||||
{/* voiceVersion에 따라 하나의 마이크만 표시 */}
|
||||
{microphoneButton}
|
||||
|
||||
{/* 디버그 토글 버튼 (DEBUG_MODE에서만 표시) */}
|
||||
{DEBUG_MODE && debugToggleButton}
|
||||
|
||||
{/* VUI_DISABLE_START - VUI 마이크 버튼 비활성화 */}
|
||||
{/* {voiceVersion === VOICE_VERSION.VUI && (
|
||||
<SpottableMicButton
|
||||
@@ -1609,6 +1653,23 @@ const VoiceInputOverlay = ({
|
||||
|
||||
{/* WebSpeech 이벤트 전용 디버그 - 모드와 상관없이 항상 우측 하단에 표시 */}
|
||||
<WebSpeechEventDebug logs={webSpeechEventLogs} />
|
||||
|
||||
{/* 풀 대시보드 - 토글 버튼으로 열기/닫기 */}
|
||||
<VoiceDebugDashboard
|
||||
isVisible={showDashboard}
|
||||
onClose={() => setShowDashboard(false)}
|
||||
currentMode={currentMode}
|
||||
isListening={isListening}
|
||||
countdown={countdown}
|
||||
shopperHouseSearchId={shopperHouseSearchId}
|
||||
webSpeechEventLogs={webSpeechEventLogs}
|
||||
lastSTTText={lastSTTText}
|
||||
sttResponseText={sttResponseText}
|
||||
interimText={interimText}
|
||||
errorMessage={errorMessage}
|
||||
voiceInputMode={voiceInputMode}
|
||||
isBubbleClickSearch={isBubbleClickSearch}
|
||||
/>
|
||||
</div>
|
||||
</TFullPopup>
|
||||
);
|
||||
|
||||
@@ -425,3 +425,381 @@
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 디버그 토글 버튼 (마이크 버튼 옆)
|
||||
// ============================================================
|
||||
.debugToggleButton {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 1000px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 5px solid #ccc;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1003;
|
||||
position: relative;
|
||||
font-size: 48px;
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
border-color: #999;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #FFB81C; // 활성화 상태 (노란색)
|
||||
border-color: #FFB81C;
|
||||
box-shadow: 0 0 22px 0 rgba(255, 184, 28, 0.5);
|
||||
|
||||
.debugToggleIcon {
|
||||
animation: spin 0.6s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.debugToggleIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 풀 대시보드 - 오버레이
|
||||
// ============================================================
|
||||
.voiceDebugDashboardOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10000; // WebSpeechEventDebug (10006)보다 낮음
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 풀 대시보드 - 메인 컨테이너
|
||||
// ============================================================
|
||||
.voiceDebugDashboard {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 1400px;
|
||||
height: 85%;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: auto;
|
||||
pointer-events: auto;
|
||||
font-family: "LG Smart UI", monospace;
|
||||
color: white;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.8);
|
||||
animation: slideUp 0.3s ease;
|
||||
|
||||
@media (max-width: 1920px) {
|
||||
width: 95%;
|
||||
height: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 헤더
|
||||
.dashboardHeader {
|
||||
padding: 24px;
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #FFB81C; // Shoptime 브랜드 색상
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dashboardCloseBtn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 콘텐츠 (스크롤 가능)
|
||||
.dashboardContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
// 대시보드 섹션
|
||||
.dashboardSection {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
border-bottom: 2px solid rgba(76, 175, 80, 0.3);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 그리드 (현재 상태 섹션)
|
||||
.stateGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
font-size: 16px;
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.stateItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.stateLabel {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.stateValue {
|
||||
color: #2196F3;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
max-width: 60%;
|
||||
|
||||
&.active {
|
||||
color: #4CAF50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 텍스트 블록 (Text Input 섹션)
|
||||
.textBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.textItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.textLabel {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.textContent {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
color: #4CAF50;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
min-height: 40px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 블록
|
||||
.errorBlock {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
border-left: 4px solid #F44336;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
color: #FF6B6B;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 타임라인 컨테이너
|
||||
.timelineContainer {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.timelineList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.timelineItem {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&.success { border-left: 3px solid #4CAF50; }
|
||||
&.error { border-left: 3px solid #F44336; }
|
||||
&.warning { border-left: 3px solid #FF9800; }
|
||||
&.info { border-left: 3px solid #2196F3; }
|
||||
}
|
||||
|
||||
.timelineIndex {
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.timelineIcon {
|
||||
min-width: 24px;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timelineContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timelineEvent {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.eventName {
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.eventTime {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.timelineDetails {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-left: 0;
|
||||
word-break: break-word;
|
||||
padding: 4px 0;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.noEvents {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// 스크롤바 스타일링 (대시보드용)
|
||||
.dashboardContent::-webkit-scrollbar,
|
||||
.timelineContainer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.dashboardContent::-webkit-scrollbar-track,
|
||||
.timelineContainer::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dashboardContent::-webkit-scrollbar-thumb,
|
||||
.timelineContainer::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user