stock/front/src/App.vue.backup4
2026-05-12 01:48:48 +00:00

536 lines
12 KiB
Plaintext

<template>
<div class="dashboard">
<header class="header">
<h1>📈 Trading Bot Dashboard</h1>
<div class="status">
<span :class="['status-indicator', connected ? 'connected' : 'disconnected']"></span>
<span>{{ connected ? '연결됨' : '연결 끊김' }}</span>
</div>
</header>
<div class="container">
<!-- 봇 상태 -->
<section class="card">
<h2>🤖 봇 상태</h2>
<div class="stats">
<div class="stat-item">
<span class="label">잔고</span>
<span class="value">{{ formatCurrency(balance) }}</span>
</div>
<div class="stat-item">
<span class="label">보유 종목</span>
<span class="value">{{ positions.length }}개</span>
</div>
<div class="stat-item">
<span class="label">총 손익</span>
<span :class="['value', totalProfit >= 0 ? 'profit' : 'loss']">
{{ totalProfit >= 0 ? '+' : '' }}{{ formatCurrency(totalProfit) }}
</span>
</div>
</div>
</section>
<!-- 보유 종목 -->
<section class="card">
<h2>💼 보유 종목</h2>
<div v-if="positions.length === 0" class="empty">
보유 중인 종목이 없습니다
</div>
<div v-else class="positions-list">
<div v-for="pos in positions" :key="pos.code" class="position-item">
<div class="position-info">
<div class="stock-name-section"><span class="stock-name">{{ pos.name }}</span><span class="code">({{ pos.code }})</span></div>
<span class="quantity">{{ pos.quantity }}주</span>
</div>
<div class="position-price">
<span class="buy-price">매수: {{ formatCurrency(pos.buyPrice) }}</span>
<span class="current-price">현재: {{ formatCurrency(pos.currentPrice) }}</span>
<span :class="['profit', pos.profitRate >= 0 ? 'positive' : 'negative']">
{{ pos.profitRate >= 0 ? '+' : '' }}{{ pos.profitRate.toFixed(2) }}%
</span>
</div>
</div>
</div>
</section>
<!-- 거래 내역 -->
<section class="card">
<h2>📊 실시간 거래</h2>
<div v-if="trades.length === 0" class="empty">
거래 내역이 없습니다
</div>
<div v-else class="trades-list">
<div v-for="trade in trades" :key="trade.id" :class="['trade-item', trade.action]">
<span class="time">{{ formatTime(trade.timestamp) }}</span>
<span :class="['action', trade.action]">{{ trade.action === 'buy' ? '매수' : '매도' }}</span>
<span class="stock-info">
<span class="stock-name">{{ trade.stock_name || trade.stock_code }}</span>
<span class="code">({{ trade.stock_code }})</span>
</span>
<span class="quantity">{{ trade.quantity }}주</span>
<span class="price">@ {{ formatCurrency(trade.price) }}</span>
</div>
</div>
</section>
<section class="card logs">
<h2>📝 실시간 로그</h2>
<div class="log-container">
<div v-for="log in logs" :key="log.id" :class="['log-item', log.level]">
<span class="timestamp">{{ formatTime(log.timestamp) }}</span>
<span class="message">{{ log.message }}</span>
</div>
</div>
</section>
</div>
<section class="card trade-history">
<h2>📜 거래 내역 (최근 7일)</h2>
<div v-if="tradeHistory.length === 0" class="empty">
거래 내역이 없습니다
</div>
<div v-else class="trades-list">
<div v-for="(trade, idx) in tradeHistory" :key="idx" :class="['trade-item', trade.type]">
<span class="time">{{ trade.time.slice(0,2) }}:{{ trade.time.slice(2,4) }}</span>
<span :class="['action', trade.type]">{{ trade.type === 'buy' ? '매수' : '매도' }}</span>
<span class="code">{{ trade.name }}({{ trade.code }})</span>
<span class="quantity">{{ trade.quantity }}주</span>
<span class="price">{{ formatCurrency(trade.price) }}</span>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { io } from 'socket.io-client';
const connected = ref(false);
const balance = ref(0);
const totalProfit = ref(0);
const positions = ref([]);
const trades = ref([]);
const tradeHistory = ref([]);
const logs = ref([]);
let socket;
const WS_URL = import.meta.env.VITE_WS_URL || (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host;
onMounted(() => {
// Socket.io 연결
socket = io(WS_URL);
socket.on('connect', () => {
connected.value = true;
addLog('대시보드 연결됨', 'info');
});
socket.on('disconnect', () => {
connected.value = false;
addLog('대시보드 연결 끊김', 'warning');
});
socket.on('connection:success', (data) => {
addLog(data.message, 'success');
});
socket.on('dashboard:update', (data) => {
console.log('[Dashboard] Update received:', data);
balance.value = data.balance || 0;
totalProfit.value = data.totalProfit || 0;
// 거래 내역 업데이트 (최신순 정렬)
if (data.tradeHistory && Array.isArray(data.tradeHistory)) {
tradeHistory.value = data.tradeHistory.sort((a, b) => {
const dateCompare = b.date.localeCompare(a.date);
if (dateCompare !== 0) return dateCompare;
return b.time.localeCompare(a.time);
});
}
// positions는 배열로 받음
if (data.positions && Array.isArray(data.positions)) {
positions.value = data.positions.map(pos => ({
code: pos.code,
name: pos.name || pos.code,
quantity: pos.quantity,
buyPrice: pos.buyPrice,
currentPrice: pos.currentPrice,
profit: pos.profit,
profitRate: pos.profitRate
}));
console.log('[Dashboard] Positions updated:', positions.value);
}
});
socket.on('dashboard:trade', (data) => {
const trade = {
id: Date.now(),
...data,
timestamp: data.timestamp || new Date().toISOString()
};
trades.value.unshift(trade);
if (trades.value.length > 50) {
trades.value = trades.value.slice(0, 50);
}
addLog(`${data.action === 'buy' ? '매수' : '매도'}: ${data.stock_code} ${data.quantity}주 @ ${formatCurrency(data.price)}`,
data.action === 'buy' ? 'success' : 'info');
});
socket.on('dashboard:log', (data) => {
addLog(data.message, data.level || 'info');
});
});
onUnmounted(() => {
if (socket) {
socket.disconnect();
}
});
function addLog(message, level = 'info') {
logs.value.unshift({
id: Date.now() + Math.random(),
message,
level,
timestamp: new Date().toISOString()
});
if (logs.value.length > 100) {
logs.value = logs.value.slice(0, 100);
}
}
function formatCurrency(amount) {
if (typeof amount !== 'number') return '₩0';
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format(amount);
}
function formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
</script>
<style scoped>
.dashboard {
min-height: 100vh;
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
background: white;
padding: 20px 30px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header h1 {
margin: 0;
color: #333;
font-size: 28px;
}
.status {
display: flex;
align-items: center;
gap: 10px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-indicator.connected {
background: #10b981;
}
.status-indicator.disconnected {
background: #ef4444;
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
/* 실시간 로그를 2칸 차지 */
.logs {
grid-column: 1 / 3; /* 1-2번 열 */
grid-row: 2; /* 2번째 행 */
}
.trade-history {
grid-column: 3; /* 3번째 열 */
grid-row: 2; /* 2번째 행 */
}
@media (max-width: 1200px) {
.container {
grid-template-columns: repeat(2, 1fr);
}
.logs {
grid-column: 1 / 3;
}
}
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
}
.logs {
grid-column: span 1;
}
}
.card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card h2 {
margin: 0 0 20px 0;
color: #333;
font-size: 20px;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-item .label {
font-size: 14px;
color: #666;
}
.stat-item .value {
font-size: 24px;
font-weight: bold;
color: #333;
}
.stat-item .value.profit {
color: #10b981;
}
.stat-item .value.loss {
color: #ef4444;
}
.empty {
text-align: center;
color: #999;
padding: 40px 0;
}
.positions-list, .trades-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 400px;
overflow-y: auto;
}
.position-item {
padding: 15px;
background: #f9fafb;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.position-info {
display: flex;
gap: 10px;
align-items: center;
}
.position-info .code {
font-weight: bold;
color: #333;
font-size: 16px;
}
.position-info .quantity {
color: #666;
font-size: 14px;
}
.position-price {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 5px;
}
.buy-price, .current-price {
font-size: 14px;
color: #666;
}
.profit {
font-weight: bold;
font-size: 16px;
}
.profit.positive {
color: #10b981;
}
.profit.negative {
color: #ef4444;
}
.trade-item {
padding: 12px;
background: #f9fafb;
border-radius: 8px;
display: flex;
gap: 10px;
align-items: center;
font-size: 14px;
}
.trade-item.buy {
border-left: 4px solid #10b981;
}
.trade-item.sell {
border-left: 4px solid #ef4444;
}
.trade-item .time {
color: #999;
font-size: 12px;
}
.trade-item .action {
font-weight: bold;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
.trade-item .action.buy {
background: #d1fae5;
color: #065f46;
}
.trade-item .action.sell {
background: #fee2e2;
color: #991b1b;
}
.trade-item .code {
font-weight: bold;
color: #333;
}
.logs {
grid-column: 1 / -1;
min-height: 650px;
}
.log-container {
max-height: 600px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.log-item {
color: #1a202c;
color: #1a202c;
font-weight: 500;
padding: 10px;
background: #f9fafb;
border-radius: 6px;
display: flex;
gap: 10px;
font-size: 13px;
color: #1f2937;
font-family: 'Courier New', monospace;
}
.log-item .timestamp {
font-weight: 500;
color: #6b7280;
font-size: 11px;
flex-shrink: 0;
}
.log-item.info {
border-left: 3px solid #3b82f6;
}
.log-item.success {
border-left: 3px solid #10b981;
}
.log-item.warning {
border-left: 3px solid #f59e0b;
}
.log-item.error {
border-left: 3px solid #ef4444;
background: #fef2f2;
}
.stock-name-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.stock-name {
font-weight: 700 !important;
color: #000 !important;
font-size: 16px;
}
.code {
font-size: 12px;
color: #666;
}
</style>