<?php
/* ===============================
1. PHP 로직 (JSON 데이터 반환 및 기본 환경 - 구조 및 코드 유지)
=============================== */
ini_set('display_errors', 0);
date_default_timezone_set('Asia/Seoul');
if (isset($_GET['ajax'])) {
header('Content-Type: application/json');
try {
require_once "/home/www/DB/db_upbit.php";
require_once "/home/www/DB/key_upbit_trade.php";
if (isset($db_upbit)) $pdo = $db_upbit;
if (!isset($pdo)) throw new Exception("DB_ERROR");
function base64url_encode($data){ return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
function jwt_hs256($payload, $secret){
$header = ['typ'=>'JWT','alg'=>'HS256'];
$h = base64url_encode(json_encode($header));
$p = base64url_encode(json_encode($payload));
$s = base64url_encode(hash_hmac('sha256', "$h.$p", $secret, true));
return "$h.$p.$s";
}
function upbit_accounts($access, $secret){
$jwt = jwt_hs256(['access_key'=>$access,'nonce'=>uniqid('',true)], $secret);
$ch = curl_init("https://api.upbit.com/v1/accounts");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>["Authorization: Bearer {$jwt}"], CURLOPT_TIMEOUT=>2]);
$res = curl_exec($ch); curl_close($ch);
return json_decode($res, true);
}
$accounts = upbit_accounts($UPBIT_ACCESS_KEY, $UPBIT_SECRET_KEY);
$hold = []; $total_hold_qty = 0; $krw_balance = 0;
if (is_array($accounts)) {
foreach ($accounts as $a) {
if ($a['currency'] === 'KRW') { $krw_balance = (float)$a['balance']; continue; }
$qty = (float)$a['balance'] + (float)$a['locked'];
if ($qty <= 0) continue;
$market = 'KRW-' . $a['currency'];
$hold[$market] = ['qty' => $qty, 'avg' => (float)$a['avg_buy_price']];
$total_hold_qty += $qty;
}
}
$sql = "SELECT t1.* FROM daemon_upbit_Ticker t1
JOIN (SELECT market, MAX(collected_at) AS mx FROM daemon_upbit_Ticker GROUP BY market) t2
ON t1.market = t2.market AND t1.collected_at = t2.mx";
$ticker_rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
$list = []; $total_buy = 0; $total_eval = 0;
$top_profit = ['name' => '-', 'val' => -999999999];
$top_loss = ['name' => '-', 'val' => 999999999];
$profit_count = 0; $loss_count = 0;
foreach ($ticker_rows as $r) {
$m = $r['market'];
$is_hold = isset($hold[$m]);
$qty = $is_hold ? $hold[$m]['qty'] : 0;
$avg = $is_hold ? $hold[$m]['avg'] : 0;
$buy = $qty * $avg;
$eval = $qty * (float)$r['trade_price'];
$pl = $eval - $buy;
$rate = ($buy > 0) ? ($pl / $buy * 100) : 0;
if($is_hold) {
$total_buy += $buy; $total_eval += $eval;
if($pl > 0) $profit_count++;
if($pl < 0) $loss_count++;
if($pl > $top_profit['val']) { $top_profit = ['name' => $r['korean_name'], 'val' => $pl]; }
if($pl < $top_loss['val']) { $top_loss = ['name' => $r['korean_name'], 'val' => $pl]; }
}
$list[] = [
'market' => $m, 'name' => $r['korean_name'] ?? $m, 'price' => (float)$r['trade_price'],
'is_hold' => $is_hold, 'qty' => $qty, 'avg' => $avg, 'buy' => $buy, 'eval' => $eval,
'pl' => $pl, 'rate' => $rate, 'time' => date('H:i:s', strtotime($r['collected_at']))
];
}
echo json_encode([
'success' => true,
'summary' => [
'total_asset' => $total_eval + $krw_balance,
'krw_balance' => $krw_balance,
'total_pl' => $total_eval - $total_buy,
'total_rate' => ($total_buy > 0) ? (($total_eval - $total_buy) / $total_buy * 100) : 0,
'total_hold_qty' => $total_hold_qty,
'top_profit' => $top_profit,
'top_loss' => $top_loss,
'profit_count' => $profit_count,
'loss_count' => $loss_count
],
'list' => $list
]);
} catch (Exception $e) { echo json_encode(['success' => false]); }
exit;
}
// 헤더 부분 포함
require_once '/home/www/GNU/_PAGE/head.php';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>UPBIT DASHBOARD</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Noto+Sans+KR:wght@300;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #020617;
--card-bg: rgba(15, 23, 42, 0.6);
--accent-blue: #38bdf8;
--accent-purple: #818cf8;
--text-main: #f1f5f9;
--text-dim: #94a3b8;
--glass-border: rgba(255, 255, 255, 0.08);
--up-color: #fb7185;
--down-color: #38bdf8;
}
/* 요구사항 7: 페이지 접속 시 동적 효과 (Fade In + Up) */
@keyframes entranceFade {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
body { background: var(--bg-color); color: var(--text-main); font-family: 'Noto Sans KR', sans-serif; margin: 0; padding: 0 0 20px 0; overflow-x: hidden; }
#stars-container { position: fixed; top:0; left:0; width:100%; height:100%; z-index:-1; pointer-events: none; }
.dashboard-container {
width: calc(100% - 400px);
margin: 0 200px;
position: relative;
z-index: 1;
animation: entranceFade 0.8s ease-out forwards; /* 요구사항 7 */
}
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
.logo { font-family: 'Orbitron'; font-size: 1.4rem; color: var(--accent-blue); font-weight: 700; letter-spacing: 2px; }
/* 요구사항 4: 상단 항목 호버 효과 및 테두리 효과 */
.summary-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; margin-bottom: 30px; }
.summary-card {
background: var(--card-bg);
border: 1px solid var(--glass-border);
padding: 18px 15px;
border-radius: 12px;
backdrop-filter: blur(10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 동적 효과용 트랜지션 */
}
.summary-card:hover {
transform: translateY(-5px);
border-color: var(--accent-blue);
box-shadow: 0 10px 20px rgba(56, 189, 248, 0.15);
background: rgba(15, 23, 42, 0.8);
}
.summary-card label { display: block; font-size: 0.7rem; color: var(--text-dim); margin-bottom: 8px; font-weight: 500; }
.summary-card .value { font-size: 1.3rem; font-weight: 700; font-family: 'Orbitron'; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.summary-card .sub-val { font-size: 0.8rem; margin-top: 4px; opacity: 0.9; font-family: 'Orbitron'; }
/* 요구사항 5, 6: 버튼 및 검색 호버 효과 */
.control-bar { display: flex; align-items: center; gap: 25px; background: var(--card-bg); padding: 15px 20px; border-radius: 12px; border: 1px solid var(--glass-border); margin-bottom: 25px; }
.filter-row { display: flex; align-items: center; gap: 20px; flex: 1; }
.filter-group { display: flex; align-items: center; gap: 8px; }
.filter-group label { font-size: 0.65rem; color: var(--accent-blue); font-family: 'Orbitron'; font-weight: 700; text-transform: uppercase; white-space: nowrap; }
button.btn-filter {
background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); color: var(--text-dim);
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.75rem; transition: all 0.2s ease; white-space: nowrap;
}
/* 요구사항 5: 버튼 마우스 오버 효과 */
button.btn-filter:hover {
border-color: var(--accent-blue);
color: var(--text-main);
background: rgba(56, 189, 248, 0.1);
}
button.btn-filter.active { background: var(--accent-blue); color: #000; border-color: var(--accent-blue); font-weight: 700; }
button.btn-profit { color: var(--up-color); }
button.btn-profit:hover { border-color: var(--up-color); background: rgba(251, 113, 133, 0.1); }
button.btn-profit.active { background: var(--up-color); color: #fff; }
button.btn-loss { color: var(--down-color); }
button.btn-loss:hover { border-color: var(--down-color); background: rgba(56, 189, 248, 0.1); }
button.btn-loss.active { background: var(--down-color); color: #fff; }
/* 요구사항 6: 검색 마우스 오버 효과 */
.search-input {
width: 220px;
background: rgba(0,0,0,0.2);
border: 1px solid var(--glass-border);
padding: 10px 15px;
border-radius: 8px;
color: #fff;
outline: none;
font-size: 0.85rem;
transition: all 0.3s ease;
}
.search-input:hover {
border-color: var(--accent-purple);
box-shadow: 0 0 10px rgba(129, 140, 248, 0.3);
background: rgba(0,0,0,0.3);
}
.search-input:focus { border-color: var(--accent-blue); }
/* 테이블 */
.table-wrap { background: var(--card-bg); border: 1px solid var(--glass-border); border-radius: 16px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; text-align: right; }
th { background: rgba(255,255,255,0.03); padding: 15px; font-size: 0.75rem; color: var(--text-dim); cursor: pointer; border-bottom: 1px solid var(--glass-border); }
td { padding: 14px 15px; border-bottom: 1px solid var(--glass-border); font-size: 0.9rem; }
.coin-name { text-align: left; font-weight: 700; }
.coin-name span { display: block; font-size: 0.7rem; color: var(--text-dim); font-family: 'Orbitron'; font-weight: 400; }
.hold-badge { background: rgba(129, 140, 248, 0.2); color: var(--accent-purple); padding: 1px 5px; border-radius: 4px; font-size: 0.65rem; margin-left: 4px; border: 1px solid var(--accent-purple); }
.up { color: var(--up-color); }
.down { color: var(--down-color); }
.font-num { font-family: 'Orbitron', sans-serif; }
@keyframes flashUp { from { background: rgba(251, 113, 133, 0.2); } to { background: transparent; } }
@keyframes flashDown { from { background: rgba(56, 189, 248, 0.2); } to { background: transparent; } }
.flash-up { animation: flashUp 0.5s ease-out; }
.flash-down { animation: flashDown 0.5s ease-out; }
</style>
</head>
<body>
<div id="stars-container"></div>
<div class="dashboard-container">
<header>
<div class="logo">UPBIT TERMINAL</div>
<div id="connection" style="font-size:0.65rem; color:#10b981;"><i class="fa-solid fa-bolt-lightning"></i> REAL-TIME ACTIVE</div>
</header>
<div class="summary-grid">
<div class="summary-card">
<label>총 자산</label>
<div id="total_asset" class="value">0</div>
<div id="total_pl" class="sub-val">0</div>
</div>
<div class="summary-card">
<label>현금 / 수익률</label>
<div id="krw_balance" class="value">0</div>
<div id="total_rate" class="sub-val">0%</div>
</div>
<div class="summary-card">
<label>총 보유 수량</label>
<div id="total_hold_qty" class="value">0</div>
<div class="sub-val" style="opacity:0.5; font-size:0.6rem;">COIN TOTAL</div>
</div>
<div class="summary-card">
<label>최고 수익 종목</label>
<div id="top_profit_name" class="value" style="color:var(--up-color); font-size:1.1rem;">-</div>
<div id="top_profit_val" class="sub-val up">0</div>
</div>
<div class="summary-card">
<label>최대 손실 종목</label>
<div id="top_loss_name" class="value" style="color:var(--down-color); font-size:1.1rem;">-</div>
<div id="top_loss_val" class="sub-val down">0</div>
</div>
<div class="summary-card">
<label>수익 종목 수</label>
<div id="profit_count" class="value" style="color:var(--up-color);">0</div>
<div class="sub-val">ITEMS</div>
</div>
<div class="summary-card">
<label>손실 종목 수</label>
<div id="loss_count" class="value" style="color:var(--down-color);">0</div>
<div class="sub-val">ITEMS</div>
</div>
</div>
<div class="control-bar">
<div class="filter-row">
<div class="filter-group">
<label>기본 필터</label>
<button class="btn-filter active" onclick="setFilter('all', this)">전체</button>
<button class="btn-filter" onclick="setFilter('hold', this)">보유</button>
<button class="btn-filter" onclick="setFilter('none', this)">미보유</button>
</div>
<div class="filter-group">
<label>수익 필터</label>
<button class="btn-filter btn-profit" onclick="setFilter('p5', this)">5%↑</button>
<button class="btn-filter btn-profit" onclick="setFilter('p10', this)">10%↑</button>
</div>
<div class="filter-group">
<label>손실 필터</label>
<button class="btn-filter btn-loss" onclick="setFilter('l5', this)">-5%↓</button>
<button class="btn-filter btn-loss" onclick="setFilter('l10', this)">-10%↓</button>
<button class="btn-filter btn-loss" onclick="setFilter('l30', this)">-30%↓</button>
</div>
</div>
<input type="text" id="searchInput" class="search-input" placeholder="자산명/심볼 검색..." oninput="setSearch(this.value)">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="text-align:left;" onclick="setSort('name')">자산명</th>
<th onclick="setSort('price')">현재가</th>
<th onclick="setSort('qty')">보유수량</th>
<th onclick="setSort('avg')">평단가</th>
<th onclick="setSort('eval')">평가금액</th>
<th onclick="setSort('pl')">손익</th>
<th onclick="setSort('rate')">수익률</th>
</tr>
</thead>
<tbody id="ticker_body"></tbody>
</table>
</div>
</div>
<script>
let rawData = [];
let prevPrices = {};
let filterMode = 'all';
let sortKey = 'eval';
let sortDir = 'desc';
let searchTerm = '';
const nf = (v, d = 0) => new Intl.NumberFormat('ko-KR', { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
async function fetchData() {
try {
const res = await fetch(window.location.pathname + "?ajax=1");
const data = await res.json();
if (!data.success) return;
rawData = data.list;
const s = data.summary;
document.getElementById('total_asset').textContent = nf(s.total_asset);
document.getElementById('krw_balance').textContent = nf(s.krw_balance);
document.getElementById('total_hold_qty').textContent = nf(s.total_hold_qty, 2);
const plEl = document.getElementById('total_pl');
plEl.textContent = (s.total_pl >= 0 ? '+' : '') + nf(s.total_pl);
plEl.className = 'sub-val ' + (s.total_pl >= 0 ? 'up' : 'down');
const rateEl = document.getElementById('total_rate');
rateEl.textContent = nf(s.total_rate, 2) + "%";
rateEl.className = 'sub-val ' + (s.total_rate >= 0 ? 'up' : 'down');
document.getElementById('top_profit_name').textContent = s.top_profit.name;
document.getElementById('top_profit_val').textContent = "+" + nf(s.top_profit.val);
document.getElementById('top_loss_name').textContent = s.top_loss.name;
document.getElementById('top_loss_val').textContent = nf(s.top_loss.val);
document.getElementById('profit_count').textContent = s.profit_count;
document.getElementById('loss_count').textContent = s.loss_count;
renderTable();
} catch (e) { }
}
function renderTable() {
const tbody = document.getElementById('ticker_body');
let filtered = rawData.filter(item => {
const matchesSearch = item.name.includes(searchTerm) || item.market.toLowerCase().includes(searchTerm.toLowerCase());
if (!matchesSearch) return false;
switch(filterMode) {
case 'hold': return item.is_hold;
case 'none': return !item.is_hold;
case 'p5': return item.is_hold && item.rate >= 5;
case 'p10': return item.is_hold && item.rate >= 10;
case 'l5': return item.is_hold && item.rate <= -5;
case 'l10': return item.is_hold && item.rate <= -10;
case 'l30': return item.is_hold && item.rate <= -30;
default: return true;
}
});
filtered.sort((a, b) => {
let v1 = a[sortKey]; let v2 = b[sortKey];
return sortDir === 'desc' ? (v2 > v1 ? 1 : -1) : (v1 > v2 ? 1 : -1);
});
let html = '';
filtered.forEach(item => {
const prevPrice = prevPrices[item.market] || item.price;
let flashClass = '';
if (item.price > prevPrice) flashClass = 'flash-up';
else if (item.price < prevPrice) flashClass = 'flash-down';
prevPrices[item.market] = item.price;
html += `
<tr class="${flashClass}">
<td class="coin-name">${item.name} <span>${item.market} ${item.is_hold ? '<b class="hold-badge">보유</b>' : ''}</span></td>
<td class="font-num ${item.pl >= 0 ? 'up' : 'down'}">${nf(item.price)}</td>
<td class="font-num">${item.is_hold ? nf(item.qty, 4) : '-'}</td>
<td class="font-num">${item.is_hold ? nf(item.avg) : '-'}</td>
<td class="font-num">${item.is_hold ? nf(item.eval) : '-'}</td>
<td class="font-num ${item.pl >= 0 ? 'up' : 'down'}">${item.is_hold ? nf(item.pl) : '-'}</td>
<td class="font-num ${item.pl >= 0 ? 'up' : 'down'}">${item.is_hold ? nf(item.rate, 2) + '%' : '-'}</td>
</tr>
`;
});
tbody.innerHTML = html;
}
function setFilter(mode, btn) {
filterMode = mode;
document.querySelectorAll('.btn-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderTable();
}
function setSort(key) {
if (sortKey === key) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
else { sortKey = key; sortDir = 'desc'; }
renderTable();
}
function setSearch(val) { searchTerm = val; renderTable(); }
function initStars() {
const container = document.getElementById('stars-container');
if(!container) return;
for (let i = 0; i < 100; i++) {
const star = document.createElement('div');
star.style.position = 'absolute';
star.style.width = Math.random() * 2 + 'px';
star.style.height = star.style.width;
star.style.background = '#fff';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
star.style.opacity = Math.random();
container.appendChild(star);
}
}
initStars();
setInterval(fetchData, 300);
fetchData();
</script>
</body>
</html>
<?php require_once '/home/www/GNU/_PAGE/tail.php'; ?>