[add] requirements.txt

This commit is contained in:
kimheesu 2025-07-03 17:23:10 +09:00
parent 0edd2c615b
commit 0e73ae0ceb
32 changed files with 3 additions and 3079 deletions

1
.gitignore vendored
View File

@ -8,4 +8,3 @@ eval_data/
*.egg-info/
results/
.env
wallet/

View File

@ -26,13 +26,15 @@ httpcore==1.0.9
httptools==0.6.4
httpx==0.28.1
idna==3.10
iniconfig==2.1.1
iniconfig==2.1.0
Jinja2==3.1.6
kombu==5.5.4
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
mocker==1.1.1
mysqlclient==2.2.7
oracledb==3.2.0
packaging==25.0
passlib==1.7.4
pluggy==1.6.0

View File

@ -1,18 +0,0 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
DB_USER: str
DB_PASSWORD: str
WALLET_PASSWORD: str
DB_DSN: str
SECRET_KEY: str
@lru_cache
def get_settings():
return Settings()

View File

View File

@ -1,96 +0,0 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import koKR from 'antd/locale/ko_KR';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
import { ThemeProvider } from 'styled-components';
import GlobalStyle from './styles/GlobalStyle';
import theme from './styles/theme';
// Components
import Layout from './components/Layout/Layout';
import Login from './pages/Login/Login';
import Register from './pages/Register/Register';
import Dashboard from './pages/Dashboard/Dashboard';
import Analysis from './pages/Analysis/Analysis';
import History from './pages/History/History';
import Profile from './pages/Profile/Profile';
import Loading from './components/Loading/Loading';
// Protected Route Component
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) {
return <Loading />;
}
return user ? children : <Navigate to="/login" />;
};
// Public Route Component (redirect to dashboard if already logged in)
const PublicRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) {
return <Loading />;
}
return !user ? children : <Navigate to="/dashboard" />;
};
function App() {
return (
<ConfigProvider locale={koKR}>
<ThemeProvider theme={theme}>
<GlobalStyle />
<AuthProvider>
<Router>
<Routes>
{/* Public Routes */}
<Route
path="/login"
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<Register />
</PublicRoute>
}
/>
{/* Protected Routes */}
<Route
path="/*"
element={
<ProtectedRoute>
<WebSocketProvider>
<Layout>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/history" element={<History />} />
<Route path="/profile" element={<Profile />} />
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
</Layout>
</WebSocketProvider>
</ProtectedRoute>
}
/>
</Routes>
</Router>
</AuthProvider>
</ThemeProvider>
</ConfigProvider>
);
}
export default App;

View File

@ -1,262 +0,0 @@
import React, { useState } from 'react';
import { Layout as AntLayout, Menu, Avatar, Dropdown, Button, Badge, Typography } from 'antd';
import {
DashboardOutlined,
LineChartOutlined,
HistoryOutlined,
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
BellOutlined,
WifiOutlined,
DisconnectOutlined
} from '@ant-design/icons';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useWebSocket } from '../../contexts/WebSocketContext';
import styled from 'styled-components';
const { Header, Sider, Content } = AntLayout;
const { Title } = Typography;
const StyledLayout = styled(AntLayout)`
min-height: 100vh;
`;
const StyledHeader = styled(Header)`
background: ${props => props.theme.colors.background};
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
padding: 0 ${props => props.theme.spacing.lg};
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: ${props => props.theme.zIndex.sticky};
`;
const StyledSider = styled(Sider)`
background: ${props => props.theme.colors.background};
border-right: 1px solid ${props => props.theme.colors.borderLight};
.ant-layout-sider-trigger {
background: ${props => props.theme.colors.backgroundSecondary};
border-top: 1px solid ${props => props.theme.colors.borderLight};
color: ${props => props.theme.colors.text};
}
`;
const StyledContent = styled(Content)`
background: ${props => props.theme.colors.backgroundSecondary};
padding: ${props => props.theme.spacing.lg};
overflow-y: auto;
`;
const HeaderLeft = styled.div`
display: flex;
align-items: center;
gap: ${props => props.theme.spacing.md};
`;
const HeaderRight = styled.div`
display: flex;
align-items: center;
gap: ${props => props.theme.spacing.md};
`;
const Logo = styled.div`
display: flex;
align-items: center;
gap: ${props => props.theme.spacing.sm};
font-weight: ${props => props.theme.typography.fontWeight.bold};
font-size: ${props => props.theme.typography.fontSize.lg};
color: ${props => props.theme.colors.primary};
`;
const ConnectionStatus = styled.div`
display: flex;
align-items: center;
gap: ${props => props.theme.spacing.xs};
font-size: ${props => props.theme.typography.fontSize.sm};
color: ${props => props.connected ? props.theme.colors.success : props.theme.colors.error};
`;
const UserInfo = styled.div`
display: flex;
align-items: center;
gap: ${props => props.theme.spacing.sm};
`;
const MainLayout = ({ children }) => {
const [collapsed, setCollapsed] = useState(false);
const { user, logout } = useAuth();
const { connected, reconnectAttempts, maxReconnectAttempts } = useWebSocket();
const location = useLocation();
const navigate = useNavigate();
// 메뉴 아이템 정의
const menuItems = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '대시보드',
},
{
key: '/analysis',
icon: <LineChartOutlined />,
label: '분석 시작',
},
{
key: '/history',
icon: <HistoryOutlined />,
label: '분석 기록',
},
{
key: '/profile',
icon: <UserOutlined />,
label: '프로필',
},
];
// 사용자 드롭다운 메뉴
const userMenuItems = [
{
key: 'profile',
icon: <UserOutlined />,
label: '프로필',
onClick: () => navigate('/profile'),
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '로그아웃',
onClick: logout,
},
];
const handleMenuClick = ({ key }) => {
navigate(key);
};
const toggleCollapsed = () => {
setCollapsed(!collapsed);
};
return (
<StyledLayout>
<StyledSider
trigger={null}
collapsible
collapsed={collapsed}
width={240}
collapsedWidth={80}
>
<div style={{ padding: '16px', borderBottom: `1px solid #f0f0f0` }}>
{!collapsed ? (
<Logo>
<LineChartOutlined style={{ fontSize: '24px' }} />
TradingAgents
</Logo>
) : (
<div style={{ textAlign: 'center' }}>
<LineChartOutlined style={{ fontSize: '24px', color: '#1890ff' }} />
</div>
)}
</div>
<Menu
theme="light"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none', paddingTop: '16px' }}
/>
</StyledSider>
<AntLayout>
<StyledHeader>
<HeaderLeft>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={toggleCollapsed}
style={{
fontSize: '16px',
width: 40,
height: 40,
}}
/>
<Title level={4} style={{ margin: 0, color: '#262626' }}>
{menuItems.find(item => item.key === location.pathname)?.label || 'TradingAgents'}
</Title>
</HeaderLeft>
<HeaderRight>
{/* WebSocket 연결 상태 */}
<ConnectionStatus connected={connected}>
{connected ? (
<>
<WifiOutlined />
<span>연결됨</span>
</>
) : (
<>
<DisconnectOutlined />
<span>
{reconnectAttempts > 0
? `재연결 중... (${reconnectAttempts}/${maxReconnectAttempts})`
: '연결 끊김'
}
</span>
</>
)}
</ConnectionStatus>
{/* 알림 아이콘 */}
<Badge count={0} showZero={false}>
<Button
type="text"
icon={<BellOutlined />}
style={{ fontSize: '16px' }}
/>
</Badge>
{/* 사용자 정보 */}
<Dropdown
menu={{ items: userMenuItems }}
placement="bottomRight"
arrow
>
<UserInfo>
<Avatar
icon={<UserOutlined />}
style={{
backgroundColor: '#1890ff',
cursor: 'pointer'
}}
/>
{!collapsed && (
<span style={{ cursor: 'pointer' }}>
{user?.username || user?.email}
</span>
)}
</UserInfo>
</Dropdown>
</HeaderRight>
</StyledHeader>
<StyledContent>
{children}
</StyledContent>
</AntLayout>
</StyledLayout>
);
};
export default MainLayout;

View File

@ -1,56 +0,0 @@
import React from 'react';
import { Spin } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import styled from 'styled-components';
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: ${props => props.fullScreen ? '100vh' : '200px'};
background-color: ${props => props.fullScreen ? props.theme.colors.background : 'transparent'};
`;
const LoadingContent = styled.div`
text-align: center;
`;
const LoadingText = styled.div`
margin-top: ${props => props.theme.spacing.md};
color: ${props => props.theme.colors.textSecondary};
font-size: ${props => props.theme.typography.fontSize.base};
`;
const CustomIcon = styled(LoadingOutlined)`
font-size: ${props => props.size || '24px'};
color: ${props => props.theme.colors.primary};
`;
const Loading = ({
size = 'large',
text = '로딩 중...',
fullScreen = false,
spinning = true
}) => {
const iconSize = {
small: '16px',
default: '20px',
large: '24px',
xlarge: '32px'
};
return (
<LoadingContainer fullScreen={fullScreen}>
<LoadingContent>
<Spin
indicator={<CustomIcon size={iconSize[size]} />}
size={size}
spinning={spinning}
/>
{text && <LoadingText>{text}</LoadingText>}
</LoadingContent>
</LoadingContainer>
);
};
export default Loading;

View File

@ -1,282 +0,0 @@
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { message } from 'antd';
import api from '../services/api';
// Auth Action Types
const AUTH_ACTIONS = {
LOGIN_START: 'LOGIN_START',
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
LOGIN_FAILURE: 'LOGIN_FAILURE',
LOGOUT: 'LOGOUT',
REGISTER_START: 'REGISTER_START',
REGISTER_SUCCESS: 'REGISTER_SUCCESS',
REGISTER_FAILURE: 'REGISTER_FAILURE',
UPDATE_USER: 'UPDATE_USER',
SET_LOADING: 'SET_LOADING',
};
// Initial State
const initialState = {
user: null,
isAuthenticated: false,
loading: true,
error: null,
};
// Auth Reducer
const authReducer = (state, action) => {
switch (action.type) {
case AUTH_ACTIONS.LOGIN_START:
case AUTH_ACTIONS.REGISTER_START:
return {
...state,
loading: true,
error: null,
};
case AUTH_ACTIONS.LOGIN_SUCCESS:
case AUTH_ACTIONS.REGISTER_SUCCESS:
return {
...state,
user: action.payload.user,
isAuthenticated: true,
loading: false,
error: null,
};
case AUTH_ACTIONS.LOGIN_FAILURE:
case AUTH_ACTIONS.REGISTER_FAILURE:
return {
...state,
user: null,
isAuthenticated: false,
loading: false,
error: action.payload,
};
case AUTH_ACTIONS.LOGOUT:
return {
...state,
user: null,
isAuthenticated: false,
loading: false,
error: null,
};
case AUTH_ACTIONS.UPDATE_USER:
return {
...state,
user: { ...state.user, ...action.payload },
};
case AUTH_ACTIONS.SET_LOADING:
return {
...state,
loading: action.payload,
};
default:
return state;
}
};
// Create Context
const AuthContext = createContext();
// Auth Provider Component
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
// 로컬 스토리지에서 토큰 확인 및 사용자 정보 로드
useEffect(() => {
const initAuth = async () => {
const token = localStorage.getItem('access_token');
if (token) {
try {
// API에 토큰 설정
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
// 사용자 정보 가져오기
const response = await api.get('/api/auth/user/');
dispatch({
type: AUTH_ACTIONS.LOGIN_SUCCESS,
payload: { user: response.data },
});
} catch (error) {
// 토큰이 유효하지 않으면 제거
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
delete api.defaults.headers.common['Authorization'];
dispatch({ type: AUTH_ACTIONS.LOGOUT });
}
} else {
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: false });
}
};
initAuth();
}, []);
// 로그인 함수
const login = async (email, password) => {
dispatch({ type: AUTH_ACTIONS.LOGIN_START });
try {
const response = await api.post('/api/auth/login/', {
email,
password,
});
const { user, tokens } = response.data;
// 토큰 저장
localStorage.setItem('access_token', tokens.access);
localStorage.setItem('refresh_token', tokens.refresh);
// API 헤더에 토큰 설정
api.defaults.headers.common['Authorization'] = `Bearer ${tokens.access}`;
dispatch({
type: AUTH_ACTIONS.LOGIN_SUCCESS,
payload: { user },
});
message.success('로그인이 완료되었습니다.');
return { success: true };
} catch (error) {
const errorMessage = error.response?.data?.message ||
error.response?.data?.detail ||
'로그인 중 오류가 발생했습니다.';
dispatch({
type: AUTH_ACTIONS.LOGIN_FAILURE,
payload: errorMessage,
});
message.error(errorMessage);
return { success: false, error: errorMessage };
}
};
// 회원가입 함수
const register = async (userData) => {
dispatch({ type: AUTH_ACTIONS.REGISTER_START });
try {
const response = await api.post('/api/auth/register/', userData);
const { user, tokens } = response.data;
// 토큰 저장
localStorage.setItem('access_token', tokens.access);
localStorage.setItem('refresh_token', tokens.refresh);
// API 헤더에 토큰 설정
api.defaults.headers.common['Authorization'] = `Bearer ${tokens.access}`;
dispatch({
type: AUTH_ACTIONS.REGISTER_SUCCESS,
payload: { user },
});
message.success('회원가입이 완료되었습니다.');
return { success: true };
} catch (error) {
const errorMessage = error.response?.data?.message ||
'회원가입 중 오류가 발생했습니다.';
dispatch({
type: AUTH_ACTIONS.REGISTER_FAILURE,
payload: errorMessage,
});
message.error(errorMessage);
return { success: false, error: error.response?.data };
}
};
// 로그아웃 함수
const logout = () => {
// 토큰 제거
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// API 헤더에서 토큰 제거
delete api.defaults.headers.common['Authorization'];
dispatch({ type: AUTH_ACTIONS.LOGOUT });
message.success('로그아웃되었습니다.');
};
// 사용자 정보 업데이트
const updateUser = (userData) => {
dispatch({
type: AUTH_ACTIONS.UPDATE_USER,
payload: userData,
});
};
// 프로필 업데이트
const updateProfile = async (profileData) => {
try {
const response = await api.put('/api/auth/profile/', profileData);
// 사용자 정보 새로고침
const userResponse = await api.get('/api/auth/user/');
updateUser(userResponse.data);
message.success('프로필이 업데이트되었습니다.');
return { success: true, data: response.data };
} catch (error) {
const errorMessage = error.response?.data?.message ||
'프로필 업데이트 중 오류가 발생했습니다.';
message.error(errorMessage);
return { success: false, error: error.response?.data };
}
};
// OpenAI API 키 검증
const checkApiKey = async () => {
try {
const response = await api.post('/api/auth/check-api-key/');
return response.data;
} catch (error) {
return { valid: false, message: error.response?.data?.message || '검증 실패' };
}
};
// Context Value
const value = {
...state,
login,
register,
logout,
updateUser,
updateProfile,
checkApiKey,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
// Custom Hook
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -1,263 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { message } from 'antd';
import { useAuth } from './AuthContext';
// WebSocket Context
const WebSocketContext = createContext();
export const WebSocketProvider = ({ children }) => {
const { user, isAuthenticated } = useAuth();
const [connected, setConnected] = useState(false);
const [messages, setMessages] = useState([]);
const [analysisProgress, setAnalysisProgress] = useState({});
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const maxReconnectAttempts = 5;
// WebSocket 연결 함수
const connect = () => {
if (!isAuthenticated || !user) {
return;
}
const token = localStorage.getItem('access_token');
if (!token) {
return;
}
try {
const wsUrl = `ws://localhost:8000/ws/trading-analysis/?token=${token}`;
wsRef.current = new WebSocket(wsUrl);
wsRef.current.onopen = () => {
console.log('WebSocket 연결됨');
setConnected(true);
setReconnectAttempts(0);
// 연결 상태 확인용 ping
sendMessage({ type: 'ping', timestamp: Date.now() });
};
wsRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (error) {
console.error('WebSocket 메시지 파싱 오류:', error);
}
};
wsRef.current.onclose = (event) => {
console.log('WebSocket 연결 해제:', event.code, event.reason);
setConnected(false);
// 자동 재연결 시도 (정상적인 종료가 아닌 경우)
if (event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) {
const delay = Math.pow(2, reconnectAttempts) * 1000; // 지수 백오프
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts(prev => prev + 1);
connect();
}, delay);
}
};
wsRef.current.onerror = (error) => {
console.error('WebSocket 오류:', error);
setConnected(false);
};
} catch (error) {
console.error('WebSocket 연결 실패:', error);
setConnected(false);
}
};
// WebSocket 연결 해제 함수
const disconnect = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, 'User disconnect');
wsRef.current = null;
}
setConnected(false);
setReconnectAttempts(0);
};
// 메시지 전송 함수
const sendMessage = (data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
} else {
console.warn('WebSocket이 연결되지 않음');
}
};
// 분석 세션 구독
const subscribeToAnalysis = (sessionId) => {
sendMessage({
type: 'subscribe_analysis',
session_id: sessionId
});
};
// 메시지 처리 함수
const handleMessage = (data) => {
console.log('WebSocket 메시지 수신:', data);
switch (data.type) {
case 'connection_established':
console.log('WebSocket 연결 설정됨:', data.message);
break;
case 'pong':
// ping에 대한 응답
break;
case 'analysis_started':
setAnalysisProgress(prev => ({
...prev,
[data.session_id]: {
status: 'running',
message: data.message,
progress: 0
}
}));
message.info(data.message);
break;
case 'analysis_progress':
setAnalysisProgress(prev => ({
...prev,
[data.session_id]: {
...prev[data.session_id],
status: 'running',
message: data.content,
agent: data.agent,
progress: data.progress,
}
}));
// 새로운 메시지 추가
setMessages(prev => [...prev.slice(-50), {
id: Date.now(),
timestamp: new Date(),
type: data.message_type,
content: data.content,
agent: data.agent,
sessionId: data.session_id
}]);
break;
case 'analysis_completed':
setAnalysisProgress(prev => ({
...prev,
[data.session_id]: {
status: 'completed',
message: data.message,
progress: 100,
result: data.result,
}
}));
message.success(data.message);
break;
case 'analysis_failed':
setAnalysisProgress(prev => ({
...prev,
[data.session_id]: {
status: 'failed',
message: data.message,
progress: 0,
error: data.message
}
}));
message.error(data.message);
break;
case 'subscription_confirmed':
console.log(`분석 세션 ${data.session_id} 구독 완료`);
break;
case 'subscription_failed':
message.error(data.message);
break;
case 'error':
message.error(data.message);
break;
default:
console.log('알 수 없는 메시지 타입:', data.type);
}
};
// 분석 진행 상황 초기화
const clearAnalysisProgress = (sessionId) => {
setAnalysisProgress(prev => {
const newProgress = { ...prev };
delete newProgress[sessionId];
return newProgress;
});
};
// 메시지 목록 초기화
const clearMessages = () => {
setMessages([]);
};
// 사용자 인증 상태 변경 시 WebSocket 연결/해제
useEffect(() => {
if (isAuthenticated && user) {
connect();
} else {
disconnect();
}
return () => {
disconnect();
};
}, [isAuthenticated, user]);
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
disconnect();
};
}, []);
// Context 값
const value = {
connected,
messages,
analysisProgress,
sendMessage,
subscribeToAnalysis,
clearAnalysisProgress,
clearMessages,
reconnectAttempts,
maxReconnectAttempts
};
return (
<WebSocketContext.Provider value={value}>
{children}
</WebSocketContext.Provider>
);
};
// Custom Hook
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within a WebSocketProvider');
}
return context;
};

View File

@ -1,22 +0,0 @@
/* Reset and base styles */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}

View File

@ -1,11 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -1,129 +0,0 @@
// web/frontend/src/pages/Analysis/Analysis.js
import React, { useState, useEffect } from 'react';
import { Card, Divider, Spin, Alert, Typography, Row, Col } from 'antd';
import styled from 'styled-components';
import api from '../../services/api';
import { useWebSocket } from '../../contexts/WebSocketContext';
import AnalysisForm from './components/AnalysisForm';
import AnalysisDisplay from './components/AnalysisDisplay';
const { Title, Paragraph } = Typography;
const AnalysisContainer = styled.div`
max-width: 100%;
margin: 0 auto;
padding: ${props => props.theme.spacing.lg};
`;
const CustomPageHeader = styled(Card)`
border: none;
background-color: transparent;
.ant-card-body {
padding: 0;
}
`;
const Analysis = () => {
const [currentSessionId, setCurrentSessionId] = useState(null);
const [analysisStatus, setAnalysisStatus] = useState('idle'); // idle, running, completed, failed
const [error, setError] = useState(null);
const [finalReport, setFinalReport] = useState(null);
const { subscribeToAnalysis, analysisProgress, messages, clearMessages, clearAnalysisProgress } = useWebSocket();
// WebSocket 메시지로부터 상태 업데이트
useEffect(() => {
if (currentSessionId && analysisProgress[currentSessionId]) {
const progress = analysisProgress[currentSessionId];
setAnalysisStatus(progress.status);
if (progress.status === 'completed') {
setFinalReport(progress.result);
} else if (progress.status === 'failed') {
setError(progress.error || '분석 중 알 수 없는 오류가 발생했습니다.');
}
}
}, [analysisProgress, currentSessionId]);
// 분석 시작 핸들러
const handleStartAnalysis = async (values) => {
setAnalysisStatus('starting');
setError(null);
setFinalReport(null);
clearMessages();
if(currentSessionId) clearAnalysisProgress(currentSessionId);
try {
const response = await api.post('/api/trading/start/', values);
const { session_id } = response.data;
setCurrentSessionId(session_id);
subscribeToAnalysis(session_id);
setAnalysisStatus('running');
} catch (err) {
const errorMessage = err.response?.data?.error || '분석 시작에 실패했습니다.';
setError(errorMessage);
setAnalysisStatus('failed');
}
};
// 새 분석 시작 핸들러 (Display 컴포넌트에서 호출)
const handleNewAnalysis = () => {
if(currentSessionId) clearAnalysisProgress(currentSessionId);
setCurrentSessionId(null);
setAnalysisStatus('idle');
setError(null);
setFinalReport(null);
clearMessages();
};
const renderContent = () => {
if (analysisStatus === 'starting') {
return <div style={{ textAlign: 'center', padding: '50px' }}><Spin size="large" tip="분석을 시작하고 있습니다..." /></div>;
}
if (currentSessionId && (analysisStatus === 'running' || analysisStatus === 'completed' || analysisStatus === 'failed')) {
return (
<AnalysisDisplay
sessionId={currentSessionId}
status={analysisStatus}
progress={analysisProgress[currentSessionId]}
messages={messages.filter(m => m.sessionId === currentSessionId)}
finalReport={finalReport}
onNewAnalysis={handleNewAnalysis}
/>
);
}
return <AnalysisForm onStartAnalysis={handleStartAnalysis} loading={analysisStatus === 'starting'} />;
};
return (
<AnalysisContainer>
<CustomPageHeader>
<Title level={2}>AI 기반 주식 분석</Title>
<Paragraph type="secondary">
관심 있는 종목에 대한 심층 분석을 시작하세요.
</Paragraph>
</CustomPageHeader>
<Divider style={{ margin: '16px 0' }} />
{error && !currentSessionId && ( // Show top-level error only when no session is active
<Alert
message="오류"
description={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: '24px' }}
/>
)}
{renderContent()}
</AnalysisContainer>
);
};
export default Analysis;

View File

@ -1,110 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { Card, Progress, Timeline, Button, Result, Typography, Empty, Tag } from 'antd';
import { FileDoneOutlined, RedoOutlined } from '@ant-design/icons';
import styled from 'styled-components';
import ReportDisplay from './ReportDisplay';
const { Title, Paragraph, Text } = Typography;
const DisplayCard = styled(Card)`
border-radius: ${props => props.theme.borderRadius.lg};
box-shadow: ${props => props.theme.shadows.lg};
margin-top: ${props => props.theme.spacing.lg};
border-radius: ${props => props.theme.borderRadius.md};
background-color: ${props => props.theme.colors.backgroundSecondary};
`;
const TimelineContainer = styled.div`
max-height: 400px;
overflow-y: auto;
padding: ${props => props.theme.spacing.md};
border: 1px solid ${props => props.theme.colors.border};
border-radius: ${props => props.theme.borderRadius.md};
background-color: ${props => props.theme.colors.backgroundSecondary};
`;
const ReportContainer = styled.div`
margin-top: ${props => props.theme.spacing.lg};
`;
const agentTagColors = {
market: 'blue',
social: 'cyan',
news: 'green',
fundamentals: 'purple',
};
const AnalysisDisplay = ({ sessionId, status, progress, messages, finalReport, onNewAnalysis }) => {
const timelineEndRef = useRef(null);
useEffect(() => {
timelineEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const renderRunningState = () => (
<>
<Title level={4}>분석 진행 ...</Title>
<Paragraph type="secondary">AI 분석가들이 정보를 수집하고 분석하고 있습니다. (세션 ID: {sessionId})</Paragraph>
<Progress percent={progress?.progress || 0} status="active" />
<Paragraph style={{ textAlign: 'center', marginTop: '10px' }}>{progress?.message}</Paragraph>
<TimelineContainer>
<Timeline>
{messages.length > 0 ? messages.map(msg => (
<Timeline.Item key={msg.id}>
<Text strong>
<Tag color={agentTagColors[msg.agent] || 'default'}>{msg.agent}</Tag>
{new Date(msg.timestamp).toLocaleTimeString()}
</Text>
<Paragraph style={{ marginLeft: '10px' }}>{msg.content}</Paragraph>
</Timeline.Item>
)) : <Empty description="실시간 분석 로그가 여기에 표시됩니다." />}
</Timeline>
<div ref={timelineEndRef} />
</TimelineContainer>
</>
);
const renderCompletedState = () => (
<Result
status="success"
title="분석이 성공적으로 완료되었습니다!"
icon={<FileDoneOutlined />}
subTitle="아래에서 생성된 최종 보고서를 확인하세요."
extra={[
<Button type="primary" key="new" icon={<RedoOutlined />} onClick={onNewAnalysis}>
분석 시작
</Button>
]}
/>
);
const renderFailedState = () => (
<Result
status="error"
title="분석에 실패했습니다."
subTitle={progress?.error || '알 수 없는 오류가 발생했습니다.'}
extra={[
<Button type="primary" key="retry" icon={<RedoOutlined />} onClick={onNewAnalysis}>
다시 시도
</Button>
]}
/>
);
return (
<DisplayCard>
{status === 'running' && renderRunningState()}
{status === 'completed' && renderCompletedState()}
{status === 'failed' && renderFailedState()}
{status === 'completed' && finalReport && (
<ReportContainer>
<ReportDisplay reportData={finalReport} />
</ReportContainer>
)}
</DisplayCard>
);
};
export default AnalysisDisplay;

View File

@ -1,144 +0,0 @@
// web/frontend/src/pages/Analysis/components/AnalysisForm.js
import React from 'react';
import { Form, Input, Button, Card, Select, Slider, Checkbox, Row, Col, Typography } from 'antd';
import { FundOutlined, SendOutlined } from '@ant-design/icons';
import styled from 'styled-components';
const { Title } = Typography;
const { Option } = Select;
const FormCard = styled(Card)`
border-radius: ${props => props.theme.borderRadius.lg};
box-shadow: ${props => props.theme.shadows.lg};
`;
const analystsOptions = [
{ label: '시장 분석가 (Market)', value: 'market' },
{ label: '소셜 분석가 (Social)', value: 'social' },
{ label: '뉴스 분석가 (News)', value: 'news' },
{ label: '재무 분석가 (Fundamentals)', value: 'fundamentals' },
];
const shallowThinkerOptions = [
{ value: 'gpt-4o-mini', label: 'GPT-4o-mini - 빠르고 효율적인 모델' },
{ value: 'gpt-4.1-nano', label: 'GPT-4.1-nano - 초경량 모델' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1-mini - 준수한 성능의 컴팩트 모델' },
{ value: 'gpt-4o', label: 'GPT-4o - 표준 모델' },
];
const deepThinkerOptions = [
{ value: 'gpt-4.1-nano', label: 'GPT-4.1-nano - 초경량 모델' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1-mini - 준수한 성능의 컴팩트 모델' },
{ value: 'gpt-4o', label: 'GPT-4o - 표준 모델' },
{ value: 'o4-mini', label: 'o4-mini - 특화된 소형 추론 모델' },
{ value: 'o3-mini', label: 'o3-mini - 경량 고급 추론 모델' },
{ value: 'o3', label: 'o3 - 전체 고급 추론 모델' },
{ value: 'o1', label: 'o1 - 최상위 추론 및 문제 해결 모델' },
];
const AnalysisForm = ({ onStartAnalysis, loading }) => {
const [form] = Form.useForm();
const handleSubmit = (values) => {
onStartAnalysis(values);
};
return (
<FormCard>
<Title level={4} style={{ textAlign: 'center', marginBottom: '16px' }}> 분석 시작</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
research_depth: 3,
analysts_selected: ['market', 'news'],
shallow_thinker: 'gpt-4o-mini',
deep_thinker: 'gpt-4o'
}}
>
<Form.Item
name="ticker"
label="분석할 주식 Ticker"
rules={[{ required: true, message: 'Ticker를 입력해주세요 (예: AAPL, TSLA)' }]}
>
<Input
prefix={<FundOutlined />}
placeholder="예: AAPL, GOOGL, MSFT"
size="large"
/>
</Form.Item>
<Form.Item
name="analysts_selected"
label="분석가 팀 선택"
rules={[{ required: true, message: '최소 한 명의 분석가를 선택해주세요.' }]}
>
<Checkbox.Group style={{ width: '100%' }}>
<Row>
{analystsOptions.map(option => (
<Col span={12} key={option.value}>
<Checkbox value={option.value}>{option.label}</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item
name="research_depth"
label="분석 깊이"
>
<Slider
min={1}
max={5}
step={2}
marks={{ 1: '가볍게', 3: '보통', 5: '심층' }}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="shallow_thinker" label="Shallow Thinker 모델">
<Select>
{shallowThinkerOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="deep_thinker" label="Deep Thinker 모델">
<Select>
{deepThinkerOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item style={{ marginTop: '16px' }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
icon={<SendOutlined />}
size="large"
block
>
{loading ? '분석 시작 중...' : '분석 시작'}
</Button>
</Form.Item>
</Form>
</FormCard>
);
};
export default AnalysisForm;

View File

@ -1,46 +0,0 @@
import React from 'react';
import { Card, Typography } from 'antd';
import styled from 'styled-components';
const { Title } = Typography;
const ReportWrapper = styled.div`
padding: ${props => props.theme.spacing.md};
background-color: ${props => props.theme.colors.background};
`;
const SectionCard = styled(Card)`
margin-bottom: ${props => props.theme.spacing.lg};
border-radius: ${props => props.theme.borderRadius.lg};
box-shadow: ${props => props.theme.shadows.md};
& .ant-card-head {
background-color: ${props => props.theme.colors.backgroundSecondary};
}
& .ant-card-body {
padding-top: 16px;
padding-bottom: 16px;
}
`;
const ReportDisplay = ({ reportData }) => {
if (!reportData) {
return null;
}
return (
<ReportWrapper>
<SectionCard title="최종 분석 보고서 (원본)">
<pre style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1.6'
}}>
{reportData}
</pre>
</SectionCard>
</ReportWrapper>
);
};
export default ReportDisplay;

View File

@ -1,354 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Typography, Button, Table, Tag, Space } from 'antd';
import {
LineChartOutlined,
TrophyOutlined,
ClockCircleOutlined,
RocketOutlined,
PlayCircleOutlined,
EyeOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useWebSocket } from '../../contexts/WebSocketContext';
import { tradingAPI } from '../../services/api';
import styled from 'styled-components';
import Loading from '../../components/Loading/Loading';
const { Title, Text } = Typography;
const DashboardContainer = styled.div`
max-width: 1200px;
margin: 0 auto;
`;
const WelcomeCard = styled(Card)`
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
color: white;
margin-bottom: ${props => props.theme.spacing.lg};
border: none;
.ant-card-body {
padding: ${props => props.theme.spacing.xl};
}
`;
const WelcomeContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
@media (max-width: ${props => props.theme.breakpoints.md}) {
flex-direction: column;
text-align: center;
gap: ${props => props.theme.spacing.lg};
}
`;
const WelcomeText = styled.div`
h2 {
color: white !important;
margin-bottom: ${props => props.theme.spacing.sm};
}
p {
color: rgba(255, 255, 255, 0.85);
font-size: ${props => props.theme.typography.fontSize.lg};
margin: 0;
}
`;
const QuickActions = styled.div`
display: flex;
gap: ${props => props.theme.spacing.md};
@media (max-width: ${props => props.theme.breakpoints.sm}) {
flex-direction: column;
width: 100%;
}
`;
const StatsCard = styled(Card)`
height: 100%;
.ant-statistic-title {
color: ${props => props.theme.colors.textSecondary};
font-weight: ${props => props.theme.typography.fontWeight.medium};
}
.ant-statistic-content {
color: ${props => props.theme.colors.text};
}
`;
const RecentAnalysisCard = styled(Card)`
.ant-card-head-title {
font-weight: ${props => props.theme.typography.fontWeight.semibold};
}
`;
const Dashboard = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
totalAnalyses: 0,
runningAnalyses: 0,
completedAnalyses: 0,
thisMonth: 0
});
const [recentAnalyses, setRecentAnalyses] = useState([]);
const { user } = useAuth();
const { connected, analysisProgress } = useWebSocket();
const navigate = useNavigate();
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
// 분석 기록 가져오기
const historyResponse = await tradingAPI.getAnalysisHistory();
const analyses = historyResponse.data.results || [];
// 실행 중인 분석 가져오기
const runningResponse = await tradingAPI.getRunningAnalyses();
const runningAnalyses = runningResponse.data.results || [];
// 통계 계산
const totalCount = analyses.length;
const runningCount = runningAnalyses.length;
const completedCount = analyses.filter(a => a.status === 'completed').length;
// 이번 달 분석 수
const currentMonth = new Date().getMonth();
const currentYear = new Date().getFullYear();
const thisMonthCount = analyses.filter(analysis => {
const analysisDate = new Date(analysis.created_at);
return analysisDate.getMonth() === currentMonth &&
analysisDate.getFullYear() === currentYear;
}).length;
setStats({
totalAnalyses: totalCount,
runningAnalyses: runningCount,
completedAnalyses: completedCount,
thisMonth: thisMonthCount
});
// 최근 분석 5개만 표시
setRecentAnalyses(analyses.slice(0, 5));
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status) => {
const colors = {
pending: 'orange',
running: 'blue',
completed: 'green',
failed: 'red',
cancelled: 'default'
};
return colors[status] || 'default';
};
const getStatusText = (status) => {
const texts = {
pending: '대기 중',
running: '실행 중',
completed: '완료',
failed: '실패',
cancelled: '취소됨'
};
return texts[status] || status;
};
const columns = [
{
title: '종목',
dataIndex: 'ticker',
key: 'ticker',
render: (ticker) => <strong>{ticker}</strong>
},
{
title: '분석 날짜',
dataIndex: 'analysis_date',
key: 'analysis_date',
},
{
title: '상태',
dataIndex: 'status',
key: 'status',
render: (status) => (
<Tag color={getStatusColor(status)}>
{getStatusText(status)}
</Tag>
)
},
{
title: '생성일',
dataIndex: 'created_at',
key: 'created_at',
render: (date) => new Date(date).toLocaleDateString('ko-KR')
},
{
title: '작업',
key: 'actions',
render: (_, record) => (
<Space>
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => navigate(`/history`)}
>
보기
</Button>
</Space>
)
}
];
if (loading) {
return <Loading text="대시보드를 로드하는 중..." />;
}
return (
<DashboardContainer>
{/* 환영 메시지 */}
<WelcomeCard>
<WelcomeContent>
<WelcomeText>
<Title level={2}>
안녕하세요, {user?.first_name || user?.username}! 👋
</Title>
<Text>
AI 기반 거래 분석으로 나은 투자 결정을 내려보세요.
</Text>
</WelcomeText>
<QuickActions>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate('/analysis')}
style={{
background: 'rgba(255, 255, 255, 0.2)',
borderColor: 'rgba(255, 255, 255, 0.4)',
color: 'white'
}}
>
분석 시작
</Button>
<Button
size="large"
icon={<EyeOutlined />}
onClick={() => navigate('/history')}
style={{
background: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.3)',
color: 'white'
}}
>
분석 기록
</Button>
</QuickActions>
</WelcomeContent>
</WelcomeCard>
{/* 통계 카드들 */}
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
<Col xs={24} sm={12} lg={6}>
<StatsCard>
<Statistic
title="총 분석 수"
value={stats.totalAnalyses}
prefix={<LineChartOutlined />}
/>
</StatsCard>
</Col>
<Col xs={24} sm={12} lg={6}>
<StatsCard>
<Statistic
title="실행 중"
value={stats.runningAnalyses}
prefix={<PlayCircleOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</StatsCard>
</Col>
<Col xs={24} sm={12} lg={6}>
<StatsCard>
<Statistic
title="완료된 분석"
value={stats.completedAnalyses}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</StatsCard>
</Col>
<Col xs={24} sm={12} lg={6}>
<StatsCard>
<Statistic
title="이번 달"
value={stats.thisMonth}
prefix={<ClockCircleOutlined />}
/>
</StatsCard>
</Col>
</Row>
{/* 최근 분석 */}
<RecentAnalysisCard
title="최근 분석"
extra={
<Button type="link" onClick={() => navigate('/history')}>
모두 보기
</Button>
}
>
{recentAnalyses.length > 0 ? (
<Table
columns={columns}
dataSource={recentAnalyses}
pagination={false}
rowKey="id"
size="small"
/>
) : (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Text type="secondary">아직 분석 기록이 없습니다.</Text>
<br />
<Button
type="primary"
style={{ marginTop: '16px' }}
onClick={() => navigate('/analysis')}
>
번째 분석 시작하기
</Button>
</div>
)}
</RecentAnalysisCard>
{/* WebSocket 연결 상태 정보 */}
{!connected && (
<Card
style={{ marginTop: '16px', borderColor: '#ff4d4f' }}
size="small"
>
<Text type="danger">
실시간 업데이트 연결이 끊어졌습니다. 일부 기능이 제한될 있습니다.
</Text>
</Card>
)}
</DashboardContainer>
);
};
export default Dashboard;

View File

@ -1,33 +0,0 @@
import React from 'react';
import { Card, Typography } from 'antd';
import styled from 'styled-components';
const { Title, Text } = Typography;
const HistoryContainer = styled.div`
max-width: 1200px;
margin: 0 auto;
`;
const PlaceholderCard = styled(Card)`
text-align: center;
padding: ${props => props.theme.spacing.xl};
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
`;
const History = () => {
return (
<HistoryContainer>
<PlaceholderCard>
<Title level={2}>분석 기록 페이지</Title>
<Text type="secondary">
여기에 사용자의 모든 분석 기록이 표시됩니다.
<br />
테이블 형태로 분석 결과를 확인하고 필터링할 있습니다.
</Text>
</PlaceholderCard>
</HistoryContainer>
);
};
export default History;

View File

@ -1,197 +0,0 @@
import React, { useState } from 'react';
import { Form, Input, Button, Card, Typography, Alert, Divider } from 'antd';
import { UserOutlined, LockOutlined, LineChartOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import styled from 'styled-components';
const { Title, Text } = Typography;
const LoginContainer = styled.div`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
padding: ${props => props.theme.spacing.lg};
`;
const LoginCard = styled(Card)`
width: 100%;
max-width: 400px;
box-shadow: ${props => props.theme.shadows.xl};
border: none;
border-radius: ${props => props.theme.borderRadius.lg};
`;
const LogoSection = styled.div`
text-align: center;
margin-bottom: ${props => props.theme.spacing.xl};
`;
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: ${props => props.theme.spacing.sm};
margin-bottom: ${props => props.theme.spacing.md};
`;
const LogoIcon = styled(LineChartOutlined)`
font-size: 32px;
color: ${props => props.theme.colors.primary};
`;
const LogoText = styled(Title)`
margin: 0;
color: ${props => props.theme.colors.primary};
font-weight: ${props => props.theme.typography.fontWeight.bold};
`;
const SubTitle = styled(Text)`
color: ${props => props.theme.colors.textSecondary};
font-size: ${props => props.theme.typography.fontSize.base};
`;
const StyledForm = styled(Form)`
.ant-form-item {
margin-bottom: ${props => props.theme.spacing.lg};
}
`;
const LoginButton = styled(Button)`
width: 100%;
height: 44px;
font-size: ${props => props.theme.typography.fontSize.base};
font-weight: ${props => props.theme.typography.fontWeight.medium};
`;
const RegisterLink = styled.div`
text-align: center;
margin-top: ${props => props.theme.spacing.lg};
color: ${props => props.theme.colors.textSecondary};
`;
const Login = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (values) => {
setLoading(true);
setError('');
try {
const result = await login(values.email, values.password);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error || '로그인에 실패했습니다.');
}
} catch (error) {
setError('로그인 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const handleFormChange = () => {
if (error) {
setError('');
}
};
return (
<LoginContainer>
<LoginCard>
<LogoSection>
<Logo>
<LogoIcon />
<LogoText level={2}>TradingAgents</LogoText>
</Logo>
<SubTitle>AI 거래 분석 플랫폼에 로그인하세요</SubTitle>
</LogoSection>
{error && (
<Alert
message={error}
type="error"
showIcon
style={{ marginBottom: 24 }}
/>
)}
<StyledForm
form={form}
name="login"
onFinish={handleSubmit}
onValuesChange={handleFormChange}
layout="vertical"
size="large"
>
<Form.Item
label="이메일"
name="email"
rules={[
{
required: true,
message: '이메일을 입력해주세요.',
},
{
type: 'email',
message: '올바른 이메일 형식을 입력해주세요.',
},
]}
>
<Input
prefix={<UserOutlined />}
placeholder="이메일을 입력하세요"
autoComplete="email"
/>
</Form.Item>
<Form.Item
label="비밀번호"
name="password"
rules={[
{
required: true,
message: '비밀번호를 입력해주세요.',
},
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="비밀번호를 입력하세요"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item>
<LoginButton
type="primary"
htmlType="submit"
loading={loading}
>
로그인
</LoginButton>
</Form.Item>
</StyledForm>
<Divider>또는</Divider>
<RegisterLink>
계정이 없으신가요?{' '}
<Link to="/register" style={{ fontWeight: 500 }}>
회원가입
</Link>
</RegisterLink>
</LoginCard>
</LoginContainer>
);
};
export default Login;

View File

@ -1,33 +0,0 @@
import React from 'react';
import { Card, Typography } from 'antd';
import styled from 'styled-components';
const { Title, Text } = Typography;
const ProfileContainer = styled.div`
max-width: 800px;
margin: 0 auto;
`;
const PlaceholderCard = styled(Card)`
text-align: center;
padding: ${props => props.theme.spacing.xl};
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
`;
const Profile = () => {
return (
<ProfileContainer>
<PlaceholderCard>
<Title level={2}>프로필 설정 페이지</Title>
<Text type="secondary">
여기에 사용자 프로필 설정 기능이 들어갑니다.
<br />
개인정보 수정, OpenAI API 설정, 기본 분석 옵션 설정 등이 포함됩니다.
</Text>
</PlaceholderCard>
</ProfileContainer>
);
};
export default Profile;

View File

@ -1,298 +0,0 @@
import React, { useState } from 'react';
import { Form, Input, Button, Card, Typography, Alert, Divider } from 'antd';
import { UserOutlined, LockOutlined, MailOutlined, LineChartOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import styled from 'styled-components';
const { Title, Text } = Typography;
const RegisterContainer = styled.div`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
padding: ${props => props.theme.spacing.lg};
`;
const RegisterCard = styled(Card)`
width: 100%;
max-width: 450px;
box-shadow: ${props => props.theme.shadows.xl};
border: none;
border-radius: ${props => props.theme.borderRadius.lg};
`;
const LogoSection = styled.div`
text-align: center;
margin-bottom: ${props => props.theme.spacing.xl};
`;
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: ${props => props.theme.spacing.sm};
margin-bottom: ${props => props.theme.spacing.md};
`;
const LogoIcon = styled(LineChartOutlined)`
font-size: 32px;
color: ${props => props.theme.colors.primary};
`;
const LogoText = styled(Title)`
margin: 0;
color: ${props => props.theme.colors.primary};
font-weight: ${props => props.theme.typography.fontWeight.bold};
`;
const SubTitle = styled(Text)`
color: ${props => props.theme.colors.textSecondary};
font-size: ${props => props.theme.typography.fontSize.base};
`;
const StyledForm = styled(Form)`
.ant-form-item {
margin-bottom: ${props => props.theme.spacing.md};
}
`;
const RegisterButton = styled(Button)`
width: 100%;
height: 44px;
font-size: ${props => props.theme.typography.fontSize.base};
font-weight: ${props => props.theme.typography.fontWeight.medium};
`;
const LoginLink = styled.div`
text-align: center;
margin-top: ${props => props.theme.spacing.lg};
color: ${props => props.theme.colors.textSecondary};
`;
const Register = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (values) => {
setLoading(true);
setErrors({});
try {
const result = await register({
email: values.email,
username: values.username,
first_name: values.firstName,
last_name: values.lastName,
password: values.password,
password_confirm: values.confirmPassword,
});
if (result.success) {
navigate('/dashboard');
} else {
if (result.error && typeof result.error === 'object') {
setErrors(result.error);
} else {
setErrors({ general: result.error || '회원가입에 실패했습니다.' });
}
}
} catch (error) {
setErrors({ general: '회원가입 중 오류가 발생했습니다.' });
} finally {
setLoading(false);
}
};
const handleFormChange = () => {
if (Object.keys(errors).length > 0) {
setErrors({});
}
};
// 에러 메시지 포맷팅
const getErrorMessage = (fieldName) => {
const error = errors[fieldName];
if (Array.isArray(error)) {
return error[0];
}
return error;
};
return (
<RegisterContainer>
<RegisterCard>
<LogoSection>
<Logo>
<LogoIcon />
<LogoText level={2}>TradingAgents</LogoText>
</Logo>
<SubTitle>AI 거래 분석 플랫폼에 가입하세요</SubTitle>
</LogoSection>
{errors.general && (
<Alert
message={errors.general}
type="error"
showIcon
style={{ marginBottom: 24 }}
/>
)}
<StyledForm
form={form}
name="register"
onFinish={handleSubmit}
onValuesChange={handleFormChange}
layout="vertical"
size="large"
>
<Form.Item
label="이메일"
name="email"
validateStatus={errors.email ? 'error' : ''}
help={getErrorMessage('email')}
rules={[
{
required: true,
message: '이메일을 입력해주세요.',
},
{
type: 'email',
message: '올바른 이메일 형식을 입력해주세요.',
},
]}
>
<Input
prefix={<MailOutlined />}
placeholder="이메일을 입력하세요"
autoComplete="email"
/>
</Form.Item>
<Form.Item
label="사용자명"
name="username"
validateStatus={errors.username ? 'error' : ''}
help={getErrorMessage('username')}
rules={[
{
required: true,
message: '사용자명을 입력해주세요.',
},
{
min: 3,
message: '사용자명은 최소 3자 이상이어야 합니다.',
},
]}
>
<Input
prefix={<UserOutlined />}
placeholder="사용자명을 입력하세요"
autoComplete="username"
/>
</Form.Item>
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
label="성"
name="lastName"
validateStatus={errors.last_name ? 'error' : ''}
help={getErrorMessage('last_name')}
style={{ flex: 1 }}
>
<Input placeholder="성을 입력하세요" />
</Form.Item>
<Form.Item
label="이름"
name="firstName"
validateStatus={errors.first_name ? 'error' : ''}
help={getErrorMessage('first_name')}
style={{ flex: 1 }}
>
<Input placeholder="이름을 입력하세요" />
</Form.Item>
</div>
<Form.Item
label="비밀번호"
name="password"
validateStatus={errors.password ? 'error' : ''}
help={getErrorMessage('password')}
rules={[
{
required: true,
message: '비밀번호를 입력해주세요.',
},
{
min: 8,
message: '비밀번호는 최소 8자 이상이어야 합니다.',
},
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="비밀번호를 입력하세요"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label="비밀번호 확인"
name="confirmPassword"
validateStatus={errors.password_confirm ? 'error' : ''}
help={getErrorMessage('password_confirm')}
dependencies={['password']}
rules={[
{
required: true,
message: '비밀번호 확인을 입력해주세요.',
},
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('비밀번호가 일치하지 않습니다.'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="비밀번호를 다시 입력하세요"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item style={{ marginTop: '24px' }}>
<RegisterButton
type="primary"
htmlType="submit"
loading={loading}
>
회원가입
</RegisterButton>
</Form.Item>
</StyledForm>
<Divider>또는</Divider>
<LoginLink>
이미 계정이 있으신가요?{' '}
<Link to="/login" style={{ fontWeight: 500 }}>
로그인
</Link>
</LoginLink>
</RegisterCard>
</RegisterContainer>
);
};
export default Register;

View File

@ -1,174 +0,0 @@
import axios from 'axios';
import { message } from 'antd';
// API 베이스 URL
const BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
// Axios 인스턴스 생성
const api = axios.create({
baseURL: BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// 요청 인터셉터
api.interceptors.request.use(
(config) => {
// 토큰이 있으면 헤더에 추가
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log(`API 요청: ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
console.error('API 요청 오류:', error);
return Promise.reject(error);
}
);
// 응답 인터셉터
api.interceptors.response.use(
(response) => {
console.log(`API 응답: ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`);
return response;
},
async (error) => {
const originalRequest = error.config;
console.error('API 응답 오류:', error.response?.status, error.response?.data);
// 401 오류 (인증 실패) 처리
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
try {
// 토큰 갱신 시도
const response = await axios.post(
`${BASE_URL}/api/auth/token/refresh/`,
{ refresh: refreshToken }
);
const newToken = response.data.access;
localStorage.setItem('access_token', newToken);
// 원래 요청에 새 토큰 적용
originalRequest.headers.Authorization = `Bearer ${newToken}`;
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
return api(originalRequest);
} catch (refreshError) {
console.error('토큰 갱신 실패:', refreshError);
// 리프레시 토큰도 만료된 경우 로그아웃 처리
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
delete api.defaults.headers.common['Authorization'];
// 로그인 페이지로 리디렉션
window.location.href = '/login';
message.error('세션이 만료되었습니다. 다시 로그인해주세요.');
}
} else {
// 리프레시 토큰이 없는 경우 로그아웃 처리
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
delete api.defaults.headers.common['Authorization'];
window.location.href = '/login';
message.error('인증이 필요합니다. 로그인해주세요.');
}
}
// 다른 오류들 처리
if (error.response?.status >= 500) {
message.error('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
} else if (error.response?.status === 403) {
message.error('접근 권한이 없습니다.');
} else if (error.response?.status === 404) {
message.error('요청한 리소스를 찾을 수 없습니다.');
}
return Promise.reject(error);
}
);
// API 함수들
export const authAPI = {
// 로그인
login: (email, password) =>
api.post('/api/auth/login/', { email, password }),
// 회원가입
register: (userData) =>
api.post('/api/auth/register/', userData),
// 사용자 정보 조회
getUser: () =>
api.get('/api/auth/user/'),
// 프로필 조회
getProfile: () =>
api.get('/api/auth/profile/'),
// 프로필 업데이트
updateProfile: (profileData) =>
api.put('/api/auth/profile/', profileData),
// OpenAI API 키 검증
checkApiKey: () =>
api.post('/api/auth/check-api-key/'),
// OpenAI API 키 제거
removeApiKey: () =>
api.delete('/api/auth/remove-api-key/'),
// 분석 세션 목록
getAnalysisSessions: () =>
api.get('/api/auth/sessions/'),
};
export const tradingAPI = {
// 분석 설정 정보 조회
getAnalysisConfig: () =>
api.get('/api/trading/config/'),
// 분석 옵션 조회
getAnalysisOptions: () =>
api.get('/api/trading/options/'),
// 분석 시작
startAnalysis: (analysisData) =>
api.post('/api/trading/start/', analysisData),
// 분석 상태 조회
getAnalysisStatus: (sessionId) =>
api.get(`/api/trading/status/${sessionId}/`),
// 분석 취소
cancelAnalysis: (sessionId) =>
api.post(`/api/trading/cancel/${sessionId}/`),
// 분석 기록 조회
getAnalysisHistory: () =>
api.get('/api/trading/history/'),
// 분석 보고서 조회
getAnalysisReport: (sessionId) =>
api.get(`/api/trading/report/${sessionId}/`),
// 실행 중인 분석 조회
getRunningAnalyses: () =>
api.get('/api/trading/running/'),
};
export default api;

View File

@ -1,338 +0,0 @@
import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: ${props => props.theme.typography.fontFamily};
color: ${props => props.theme.colors.text};
background-color: ${props => props.theme.colors.background};
font-size: ${props => props.theme.typography.fontSize.base};
line-height: ${props => props.theme.typography.lineHeight.normal};
}
/* Link Styles */
a {
color: ${props => props.theme.colors.primary};
text-decoration: none;
transition: color ${props => props.theme.transitions.fast};
&:hover {
color: ${props => props.theme.colors.primaryHover};
}
&:active {
color: ${props => props.theme.colors.primaryActive};
}
}
/* Button Reset */
button {
border: none;
background: none;
cursor: pointer;
font-family: inherit;
}
/* Input Reset */
input, textarea, select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
/* Remove outline on focus for non-keyboard users */
:focus:not(:focus-visible) {
outline: none;
}
/* Scrollbar Styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: ${props => props.theme.colors.backgroundTertiary};
border-radius: ${props => props.theme.borderRadius.sm};
}
::-webkit-scrollbar-thumb {
background: ${props => props.theme.colors.border};
border-radius: ${props => props.theme.borderRadius.sm};
&:hover {
background: ${props => props.theme.colors.textSecondary};
}
}
/* Ant Design Customizations */
.ant-layout {
min-height: 100vh;
}
.ant-layout-header {
background: ${props => props.theme.colors.background};
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
padding: 0 ${props => props.theme.spacing.lg};
display: flex;
align-items: center;
justify-content: space-between;
}
.ant-layout-sider {
background: ${props => props.theme.colors.background};
border-right: 1px solid ${props => props.theme.colors.borderLight};
}
.ant-layout-content {
background: ${props => props.theme.colors.backgroundSecondary};
padding: ${props => props.theme.spacing.lg};
min-height: calc(100vh - 64px);
}
.ant-menu {
background: transparent;
border-right: none;
}
.ant-menu-item {
margin: 0;
border-radius: ${props => props.theme.borderRadius.base};
margin-bottom: ${props => props.theme.spacing.xs};
&:hover {
background-color: ${props => props.theme.colors.primaryLight};
}
}
.ant-menu-item-selected {
background-color: ${props => props.theme.colors.primaryLight} !important;
&::after {
display: none;
}
}
.ant-card {
border-radius: ${props => props.theme.borderRadius.md};
box-shadow: ${props => props.theme.shadows.sm};
border: 1px solid ${props => props.theme.colors.borderLight};
}
.ant-card-head {
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
}
.ant-btn {
border-radius: ${props => props.theme.borderRadius.base};
font-weight: ${props => props.theme.typography.fontWeight.medium};
transition: all ${props => props.theme.transitions.fast};
}
.ant-btn-primary {
background-color: ${props => props.theme.colors.primary};
border-color: ${props => props.theme.colors.primary};
&:hover, &:focus {
background-color: ${props => props.theme.colors.primaryHover};
border-color: ${props => props.theme.colors.primaryHover};
}
&:active {
background-color: ${props => props.theme.colors.primaryActive};
border-color: ${props => props.theme.colors.primaryActive};
}
}
.ant-input, .ant-input-password, .ant-select-selector {
border-radius: ${props => props.theme.borderRadius.base};
transition: all ${props => props.theme.transitions.fast};
&:hover {
border-color: ${props => props.theme.colors.primaryHover};
}
&:focus, &.ant-input-focused, &.ant-select-focused .ant-select-selector {
border-color: ${props => props.theme.colors.primary};
box-shadow: 0 0 0 2px ${props => props.theme.colors.primaryLight};
}
}
.ant-form-item-label > label {
font-weight: ${props => props.theme.typography.fontWeight.medium};
color: ${props => props.theme.colors.text};
}
.ant-table {
border-radius: ${props => props.theme.borderRadius.md};
}
.ant-table-thead > tr > th {
background-color: ${props => props.theme.colors.backgroundTertiary};
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
font-weight: ${props => props.theme.typography.fontWeight.semibold};
}
.ant-table-tbody > tr:hover > td {
background-color: ${props => props.theme.colors.backgroundSecondary};
}
.ant-progress-line {
.ant-progress-bg {
transition: all ${props => props.theme.transitions.base};
}
}
.ant-tag {
border-radius: ${props => props.theme.borderRadius.base};
font-weight: ${props => props.theme.typography.fontWeight.medium};
}
.ant-notification {
.ant-notification-notice {
border-radius: ${props => props.theme.borderRadius.md};
box-shadow: ${props => props.theme.shadows.lg};
}
}
.ant-message {
.ant-message-notice-content {
border-radius: ${props => props.theme.borderRadius.md};
box-shadow: ${props => props.theme.shadows.md};
}
}
/* Custom Status Colors */
.status-pending {
color: ${props => props.theme.colors.pending};
}
.status-running {
color: ${props => props.theme.colors.running};
}
.status-completed {
color: ${props => props.theme.colors.completed};
}
.status-failed {
color: ${props => props.theme.colors.failed};
}
.status-cancelled {
color: ${props => props.theme.colors.cancelled};
}
/* Trading Colors */
.bullish {
color: ${props => props.theme.colors.bullish};
}
.bearish {
color: ${props => props.theme.colors.bearish};
}
.neutral {
color: ${props => props.theme.colors.neutral};
}
/* Utility Classes */
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-left {
text-align: left;
}
.mb-0 { margin-bottom: 0 !important; }
.mb-1 { margin-bottom: ${props => props.theme.spacing.xs} !important; }
.mb-2 { margin-bottom: ${props => props.theme.spacing.sm} !important; }
.mb-3 { margin-bottom: ${props => props.theme.spacing.md} !important; }
.mb-4 { margin-bottom: ${props => props.theme.spacing.lg} !important; }
.mb-5 { margin-bottom: ${props => props.theme.spacing.xl} !important; }
.mt-0 { margin-top: 0 !important; }
.mt-1 { margin-top: ${props => props.theme.spacing.xs} !important; }
.mt-2 { margin-top: ${props => props.theme.spacing.sm} !important; }
.mt-3 { margin-top: ${props => props.theme.spacing.md} !important; }
.mt-4 { margin-top: ${props => props.theme.spacing.lg} !important; }
.mt-5 { margin-top: ${props => props.theme.spacing.xl} !important; }
.ml-0 { margin-left: 0 !important; }
.ml-1 { margin-left: ${props => props.theme.spacing.xs} !important; }
.ml-2 { margin-left: ${props => props.theme.spacing.sm} !important; }
.ml-3 { margin-left: ${props => props.theme.spacing.md} !important; }
.ml-4 { margin-left: ${props => props.theme.spacing.lg} !important; }
.mr-0 { margin-right: 0 !important; }
.mr-1 { margin-right: ${props => props.theme.spacing.xs} !important; }
.mr-2 { margin-right: ${props => props.theme.spacing.sm} !important; }
.mr-3 { margin-right: ${props => props.theme.spacing.md} !important; }
.mr-4 { margin-right: ${props => props.theme.spacing.lg} !important; }
.p-0 { padding: 0 !important; }
.p-1 { padding: ${props => props.theme.spacing.xs} !important; }
.p-2 { padding: ${props => props.theme.spacing.sm} !important; }
.p-3 { padding: ${props => props.theme.spacing.md} !important; }
.p-4 { padding: ${props => props.theme.spacing.lg} !important; }
/* Loading Animation */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Fade Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
.animate-fade-in {
animation: fadeIn ${props => props.theme.transitions.base};
}
.animate-fade-out {
animation: fadeOut ${props => props.theme.transitions.base};
}
/* Responsive Utilities */
@media (max-width: ${props => props.theme.breakpoints.sm}) {
.ant-layout-content {
padding: ${props => props.theme.spacing.md};
}
.ant-card {
margin-bottom: ${props => props.theme.spacing.md};
}
}
`;
export default GlobalStyle;

View File

@ -1,211 +0,0 @@
// 테마 설정
const theme = {
colors: {
// Primary Colors
primary: '#1890ff',
primaryHover: '#40a9ff',
primaryActive: '#096dd9',
primaryLight: '#e6f7ff',
// Secondary Colors
secondary: '#722ed1',
secondaryHover: '#9254de',
secondaryActive: '#531dab',
// Success Colors
success: '#52c41a',
successHover: '#73d13d',
successActive: '#389e0d',
successLight: '#f6ffed',
// Warning Colors
warning: '#fa8c16',
warningHover: '#ffa940',
warningActive: '#d46b08',
warningLight: '#fff7e6',
// Error Colors
error: '#ff4d4f',
errorHover: '#ff7875',
errorActive: '#d9363e',
errorLight: '#fff2f0',
// Info Colors
info: '#1890ff',
infoHover: '#40a9ff',
infoActive: '#096dd9',
infoLight: '#e6f7ff',
// Neutral Colors
text: '#262626',
textSecondary: '#8c8c8c',
textLight: '#bfbfbf',
textDisabled: '#d9d9d9',
// Background Colors
background: '#ffffff',
backgroundSecondary: '#fafafa',
backgroundTertiary: '#f5f5f5',
// Border Colors
border: '#d9d9d9',
borderLight: '#f0f0f0',
borderSecondary: '#e6f7ff',
// Card & Surface Colors
cardBg: '#ffffff',
surfaceBg: '#fafafa',
// Trading Specific Colors
bullish: '#52c41a',
bearish: '#ff4d4f',
neutral: '#fa8c16',
// Analysis Status Colors
pending: '#faad14',
running: '#1890ff',
completed: '#52c41a',
failed: '#ff4d4f',
cancelled: '#8c8c8c',
},
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
// Font Sizes
fontSize: {
xs: '12px',
sm: '14px',
base: '16px',
lg: '18px',
xl: '20px',
'2xl': '24px',
'3xl': '28px',
'4xl': '32px',
'5xl': '36px',
},
// Font Weights
fontWeight: {
light: 300,
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
// Line Heights
lineHeight: {
tight: 1.2,
normal: 1.5,
relaxed: 1.75,
},
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
'2xl': '48px',
'3xl': '64px',
'4xl': '96px',
},
borderRadius: {
none: '0',
sm: '2px',
base: '6px',
md: '8px',
lg: '12px',
xl: '16px',
'2xl': '24px',
full: '50%',
},
shadows: {
none: 'none',
sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
base: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
md: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
lg: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
xl: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
},
breakpoints: {
xs: '480px',
sm: '576px',
md: '768px',
lg: '992px',
xl: '1200px',
xxl: '1600px',
},
zIndex: {
dropdown: 1000,
sticky: 1020,
fixed: 1030,
modalBackdrop: 1040,
modal: 1050,
popover: 1060,
tooltip: 1070,
notification: 1080,
},
transitions: {
fast: '150ms ease-in-out',
base: '250ms ease-in-out',
slow: '350ms ease-in-out',
},
// Component Specific Themes
components: {
button: {
height: {
sm: '24px',
base: '32px',
lg: '40px',
},
padding: {
sm: '4px 15px',
base: '4px 15px',
lg: '6px 15px',
},
},
input: {
height: {
sm: '24px',
base: '32px',
lg: '40px',
},
},
card: {
padding: '24px',
borderRadius: '8px',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)',
},
layout: {
header: {
height: '64px',
background: '#ffffff',
borderBottom: '1px solid #f0f0f0',
},
sidebar: {
width: '200px',
collapsedWidth: '80px',
background: '#001529',
},
content: {
padding: '24px',
background: '#fafafa',
minHeight: 'calc(100vh - 64px)',
},
},
},
};
export default theme;