d16456cb92
# 사용자별 격리 - JWT 토큰에 uid 추가 (auth.get_uid 헬퍼) - PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT - SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환) - 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가 - alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화 - core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용 - ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드 # 사용자 관리 (admin only) - users_db: delete_user / admin_reset_password / set_role - /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호) - /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달 - 사이드바 adminOnly 필터 — admin role 만 메뉴 노출 # 대시보드 개선 - 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200) - 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산) - 메트릭 카드 분리 (실거래 vs 실시간 open) # 안정성 - api.ts: error.detail array/object 안전 처리 ([object Object] 방지) - Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
4.3 KiB
TypeScript
104 lines
4.3 KiB
TypeScript
'use client';
|
|
import { useEffect, useState } from 'react';
|
|
import { api } from '@/lib/api';
|
|
import Chart from '@/components/Chart';
|
|
import { Banner, Card, PageHeader, Select, Toggle } from '@/components/ui';
|
|
import { RefreshCw } from 'lucide-react';
|
|
|
|
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT'];
|
|
const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '12h', '1d'];
|
|
const CANDLES_DESKTOP = 200;
|
|
const CANDLES_MOBILE = 60;
|
|
|
|
export default function DashboardPage() {
|
|
const [symbol, setSymbol] = useState('BTCUSDT');
|
|
const [interval, setIntervalV] = useState('5m');
|
|
const [auto, setAuto] = useState(true);
|
|
const [refresh, setRefresh] = useState(30);
|
|
const [showLegend, setShowLegend] = useState(false);
|
|
const [mobile, setMobile] = useState(false);
|
|
const [data, setData] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [tick, setTick] = useState(0);
|
|
|
|
// 첫 로드 시 viewport 자동 감지 (모바일이면 mobile 모드 default ON)
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
|
setMobile(true);
|
|
}
|
|
}, []);
|
|
|
|
const candleLimit = mobile ? CANDLES_MOBILE : CANDLES_DESKTOP;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
api.get(`/api/market/dashboard?symbol=${symbol}&interval=${interval}&limit=${candleLimit}`)
|
|
.then(d => { if (!cancelled) setData(d); })
|
|
.catch(e => { if (!cancelled) setError(e.message); })
|
|
.finally(() => { if (!cancelled) setLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [symbol, interval, tick, candleLimit]);
|
|
|
|
useEffect(() => {
|
|
if (!auto) return;
|
|
const id = setInterval(() => setTick(t => t + 1), refresh * 1000);
|
|
return () => clearInterval(id);
|
|
}, [auto, refresh]);
|
|
|
|
const now = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="💰 돈복사 대시보드"
|
|
subtitle={`${symbol} · ${interval} · ${candleLimit} 캔들 · 마지막 갱신 ${now} KST`}
|
|
right={
|
|
<button onClick={() => setTick(t => t + 1)} className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-md shadow-sm">
|
|
<RefreshCw size={14} /> 새로고침
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
<Card className="mb-4">
|
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 items-end">
|
|
<Select label="심볼" value={symbol} onChange={(e: any) => setSymbol(e.target.value)}>
|
|
{SYMBOLS.map(s => <option key={s} value={s}>{s}</option>)}
|
|
</Select>
|
|
<Select label="시간축" value={interval} onChange={(e: any) => setIntervalV(e.target.value)}>
|
|
{INTERVALS.map(s => <option key={s} value={s}>{s}</option>)}
|
|
</Select>
|
|
<div>
|
|
<span className="block text-xs font-medium text-slate-600 mb-1">갱신(초)</span>
|
|
<input type="number" value={refresh} min={10} max={300}
|
|
onChange={(e) => setRefresh(parseInt(e.target.value))}
|
|
className="w-full px-3 py-2 text-sm rounded-md border border-slate-300 bg-slate-50" />
|
|
</div>
|
|
<div className="flex items-center"><Toggle checked={auto} onChange={setAuto} label="자동 갱신" /></div>
|
|
<div className="flex items-center"><Toggle checked={mobile} onChange={setMobile} label={`모바일 (${mobile ? CANDLES_MOBILE : CANDLES_DESKTOP})`} /></div>
|
|
<div className="flex items-center"><Toggle checked={showLegend} onChange={setShowLegend} label="범례" /></div>
|
|
</div>
|
|
{(loading || data?.last_price) && (
|
|
<div className="text-xs text-slate-500 text-right mt-2 pt-2 border-t border-slate-100">
|
|
{loading ? '⏳ 로딩 중...' : `현재가: ${data.last_price.toLocaleString()} ${symbol.replace('USDT', '/USDT')}`}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{data?.banner && (
|
|
<div className="mb-4">
|
|
<Banner level={data.banner.level}>{data.banner.text}</Banner>
|
|
</div>
|
|
)}
|
|
|
|
{error && <Banner level="danger">{error}</Banner>}
|
|
|
|
<Card className="p-2 md:p-3">
|
|
<Chart rows={data?.rows || []} lastPrice={data?.last_price} mobile={mobile} showLegend={showLegend} />
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|