[add] requirements.txt
This commit is contained in:
parent
0edd2c615b
commit
0e73ae0ceb
|
|
@ -8,4 +8,3 @@ eval_data/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
results/
|
results/
|
||||||
.env
|
.env
|
||||||
wallet/
|
|
||||||
|
|
@ -26,13 +26,15 @@ httpcore==1.0.9
|
||||||
httptools==0.6.4
|
httptools==0.6.4
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
idna==3.10
|
idna==3.10
|
||||||
iniconfig==2.1.1
|
iniconfig==2.1.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
kombu==5.5.4
|
kombu==5.5.4
|
||||||
markdown-it-py==3.0.0
|
markdown-it-py==3.0.0
|
||||||
MarkupSafe==3.0.2
|
MarkupSafe==3.0.2
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
mocker==1.1.1
|
mocker==1.1.1
|
||||||
|
mysqlclient==2.2.7
|
||||||
|
oracledb==3.2.0
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
pluggy==1.6.0
|
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