[add] requirements.txt
This commit is contained in:
parent
0edd2c615b
commit
0e73ae0ceb
|
|
@ -8,4 +8,3 @@ eval_data/
|
|||
*.egg-info/
|
||||
results/
|
||||
.env
|
||||
wallet/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue