데스크톱 앱 리프레시 기능 추가

This commit is contained in:
Macbook
2026-06-22 16:47:20 +09:00
parent 1180cd53f0
commit 5871eb2465
17 changed files with 165 additions and 5 deletions
+26
View File
@@ -12664,6 +12664,32 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
color: var(--accent, #8b5cf6);
}
/* TopMenuBar 새로고침 (데스크톱) */
.tmb-refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text2);
cursor: pointer;
flex-shrink: 0;
}
.tmb-refresh-btn:hover {
background: var(--bg4);
color: var(--accent, #2196f3);
}
.tmb-refresh-btn.busy {
opacity: 0.65;
cursor: wait;
}
.tmb-refresh-btn.busy svg {
animation: tmb-update-spin 0.9s linear infinite;
}
/* TopMenuBar 전체화면 버튼 */
.tmb-fullscreen-btn {
display: flex;
+6
View File
@@ -38,6 +38,7 @@ import { invalidateAppSettingsCache } from './hooks/useAppSettings';
import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import { isDesktop } from './utils/platform';
import { syncDesktopMainWindowTitle } from './utils/desktopBridge';
import { dispatchPageRefresh } from './utils/pageRefreshEvents';
import { writeDesktopSync } from './utils/desktopSync';
const LAST_MENU_KEY = 'gc_last_menu';
@@ -185,6 +186,10 @@ function AppMainContent({
else document.exitFullscreen().catch(() => {});
};
const handlePageRefresh = useCallback(() => {
dispatchPageRefresh(menuPage);
}, [menuPage]);
return (
<VerificationIssueNotificationProvider
enabled={canMenu('verification-board')}
@@ -209,6 +214,7 @@ function AppMainContent({
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
onFullscreen={handleFullscreen}
onRefresh={isDesktop() ? handlePageRefresh : undefined}
onOpenFloatingWidgets={showFloatingWidgets ? () => setFloatingLayoutPickerOpen(true) : undefined}
floatingWidgetCount={floatingWidgetCount}
/>
+17 -1
View File
@@ -29,6 +29,7 @@ import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from '../ut
import { calcStats, type PriceStats } from '../utils/calculations';
import { saveState, DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
import { useWorkspacePersist } from '../hooks/useWorkspacePersist';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import type { WatchlistItem } from '../utils/backendApi';
import {
addWatchlistItem, removeWatchlistItem,
@@ -620,6 +621,7 @@ export function useChartWorkspace({
const [paperCashBalance, setPaperCashBalance] = useState(0);
const [paperCoinQty, setPaperCoinQty] = useState(0);
const [paperRefreshKey, setPaperRefreshKey] = useState(0);
const [chartRefreshToken, setChartRefreshToken] = useState(0);
const handlePaperOrderFilled = useCallback(() => {
setPaperRefreshKey(k => k + 1);
@@ -679,6 +681,19 @@ export function useChartWorkspace({
invalidateRuns: btInvalidateRuns,
} = useBacktest();
const [btSettings, setBtSettings] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
usePageRefresh('chart', useCallback(() => {
setChartRefreshToken(t => t + 1);
void refreshAllTickers();
void refreshPaperAccount(symbol);
btInvalidateRuns();
}, [refreshAllTickers, refreshPaperAccount, symbol, btInvalidateRuns]));
usePageRefresh('paper', useCallback(() => {
setPaperRefreshKey(k => k + 1);
void refreshAllTickers();
}, [refreshAllTickers]));
const [showBtSettings, setShowBtSettings] = useState(false);
const [showBtResult, setShowBtResult] = useState(false);
const [btStrategyName, setBtStrategyName] = useState('전략');
@@ -1160,7 +1175,8 @@ export function useChartWorkspace({
onNewCandle: handleNewCandle,
enabled: chartScreenActive && useUpbit && chartConfigReady,
source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
}), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartConfigReady, chartRealtimeSource]),
refreshToken: chartRefreshToken,
}), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartConfigReady, chartRealtimeSource, chartRefreshToken]),
);
// 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음)
@@ -22,6 +22,7 @@ import {
} from '../utils/backendApi';
import type { Theme, Timeframe } from '../types';
import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import { buildEquityFromSignals } from '../utils/backtestEquity';
import {
formatChartTypeKo,
@@ -210,6 +211,8 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
useEffect(() => { void fetchAll(); }, [fetchAll]);
usePageRefresh('backtest', () => { void fetchAll(); });
useEffect(() => {
const onTradesChanged = () => { void refreshLiveTrades(); };
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
@@ -16,6 +16,7 @@ import DashboardSegmentBar from './dashboard/DashboardSegmentBar';
import DashboardSparkChart from './dashboard/DashboardSparkChart';
import DashboardIndicatorBar from './dashboard/DashboardIndicatorBar';
import DashboardSystemCard from './dashboard/DashboardSystemCard';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import '../styles/strategyEditorTheme.css';
import '../styles/dashboardCommand.css';
@@ -110,6 +111,8 @@ const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
return () => window.clearInterval(id);
}, [refresh]);
usePageRefresh('dashboard', refresh);
const mon = data?.systemMonitor;
const app = data?.app as Record<string, unknown> | undefined;
const health = mon ? healthLevel(mon) : 'yellow';
@@ -28,6 +28,7 @@ interface NotifyTopMenuBarProps {
onFullscreen?: () => void;
onOpenFloatingWidgets?: () => void;
floatingWidgetCount?: number;
onRefresh?: () => void;
}
export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
@@ -35,6 +35,7 @@ import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import { loadVirtualSession, loadVirtualTargets } from '../utils/virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from '../utils/virtualTargetStrategy';
import { formatVirtualTargetOptionLabel } from '../utils/virtualTargetNames';
import { usePageRefresh } from '../utils/pageRefreshEvents';
type HistoryTab = 'open' | 'recent' | 'ledger';
type CenterTab = 'invest' | 'chart';
@@ -152,6 +153,8 @@ const PaperTradingPage: React.FC<Props> = ({
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
usePageRefresh('paper', () => { void loadData(); });
useEffect(() => {
if (initialLoading) return;
setVirtualTargets(loadVirtualTargets());
@@ -28,6 +28,7 @@ import StrategyEvaluationStrategyPanel from './strategyEvaluation/StrategyEvalua
import StrategyEvaluationIndicatorPalettePanel from './strategyEvaluation/StrategyEvaluationIndicatorPalettePanel';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
import StrategyEvaluationMarketTab from './strategyEvaluation/StrategyEvaluationMarketTab';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
@@ -375,6 +376,11 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
void refreshStrategies();
}, [refreshStrategies]);
usePageRefresh('strategy-evaluation', () => {
void refreshStrategies();
setParamsRevision(v => v + 1);
});
useEffect(() => {
if (!selectedStrategyId) {
setSelectedStrategy(null);
+33
View File
@@ -50,6 +50,8 @@ interface TopMenuBarProps {
onOpenFloatingWidgets?: () => void;
/** 실행 중인 플로팅 위젯 창 수 */
floatingWidgetCount?: number;
/** 데스크톱 — 현재 화면 데이터 새로고침 */
onRefresh?: () => void;
/** 메뉴별 접근 허용 (없으면 전체 표시) */
menuPermissions?: Record<string, boolean>;
}
@@ -116,6 +118,15 @@ const IcDesktopUpdate = () => (
</svg>
);
const IcRefresh = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 8a6 6 0 0 1 10.2-4.2"/>
<polyline points="12,2 12,5.5 8.5,5.5"/>
<path d="M14 8a6 6 0 0 1-10.2 4.2"/>
<polyline points="4,14 4,10.5 7.5,10.5"/>
</svg>
);
const IcNotify = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
@@ -293,10 +304,12 @@ export const TopMenuBar = memo(function TopMenuBar({
onFullscreen,
onOpenFloatingWidgets,
floatingWidgetCount = 0,
onRefresh,
menuPermissions,
}: TopMenuBarProps) {
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
const [desktopUpdateOpen, setDesktopUpdateOpen] = useState(false);
const [refreshBusy, setRefreshBusy] = useState(false);
const desktopClient = isDesktop();
const appVersion = useAppTitleBarVersion();
@@ -340,6 +353,26 @@ export const TopMenuBar = memo(function TopMenuBar({
{/* 우측 영역 */}
<div className="tmb-right">
{desktopClient && onRefresh && (
<button
type="button"
className={`tmb-refresh-btn${refreshBusy ? ' busy' : ''}`}
onClick={() => {
if (refreshBusy) return;
setRefreshBusy(true);
try {
onRefresh();
} finally {
window.setTimeout(() => setRefreshBusy(false), 600);
}
}}
title="현재 화면 새로고침"
aria-label="현재 화면 새로고침"
>
<IcRefresh />
</button>
)}
{onOpenFloatingWidgets && (
<div className="tmb-floating-widget-wrap">
<button
@@ -13,6 +13,7 @@ import {
type StrategyDto,
} from '../utils/backendApi';
import { prefetchNotificationStrategies, resolveNotificationStrategy } from '../utils/resolveNotificationStrategy';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import { normalizeMarketCode } from '../utils/strategyHydrate';
import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
@@ -182,6 +183,11 @@ export const TradeNotificationListPage: React.FC<Props> = ({
void refreshPaperData();
}, [refreshPaperData]);
usePageRefresh('notifications', () => {
void refreshHistory();
void refreshPaperData();
});
useEffect(() => {
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
@@ -30,6 +30,7 @@ import {
type TrendSearchAppSettings,
} from '../utils/trendSearchAppSettings';
import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css';
@@ -136,6 +137,8 @@ const TrendSearchPage: React.FC<Props> = ({
}
}, [applyResults]);
usePageRefresh('trend-search', () => { void loadFromDb(); });
const runScan = useCallback(async () => {
setSearching(true);
try {
@@ -16,6 +16,7 @@ import {
type VerificationIssueDto,
} from '../utils/verificationBoardApi';
import { prepareAttachmentsForUpload } from '../utils/verificationAttachmentFiles';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import '../styles/verificationBoard.css';
interface Props {
@@ -79,6 +80,8 @@ const VerificationBoardPage: React.FC<Props> = ({
void reload(stageFilter);
}, [stageFilter]); // eslint-disable-line react-hooks/exhaustive-deps
usePageRefresh('verification-board', () => { void reload(stageFilter); });
useEffect(() => {
if (focusIssueId == null) return;
setSelectedId(focusIssueId);
@@ -29,6 +29,7 @@ import {
import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard';
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
import { usePageRefresh } from '../utils/pageRefreshEvents';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
import {
@@ -261,6 +262,8 @@ const VirtualTradingPage: React.FC<Props> = ({
if (opts?.focusHistory) setRightTab('history');
}, [onPaperOrderFilled]);
usePageRefresh('virtual', () => { void refreshPaperData(); });
const handleGlobalDisplayModeChange = useCallback((mode: VirtualCardDisplayMode) => {
setGlobalDisplayMode(mode);
}, []);
@@ -12,6 +12,8 @@ interface Options {
/** 직접 지정할 페치 봉 수 — UPBIT_DIRECT 전용 (deepObvHistory·WARMUP_COUNT 무시) */
fetchCount?: number;
source?: ChartRealtimeSource;
/** 증가 시 초기 캔들 REST 재로드 (화면 새로고침) */
refreshToken?: number;
}
export function useChartRealtimeData(
@@ -25,6 +27,7 @@ export function useChartRealtimeData(
onNewCandle: opts.onNewCandle,
enabled: opts.enabled !== false && useStomp,
deepObvHistory: opts.deepObvHistory,
refreshToken: opts.refreshToken,
});
const upbit = useUpbitData(market, timeframe, {
onTickUpdate: opts.onTickUpdate,
@@ -32,6 +35,7 @@ export function useChartRealtimeData(
enabled: opts.enabled !== false && !useStomp,
deepObvHistory: opts.deepObvHistory,
fetchCount: opts.fetchCount,
refreshToken: opts.refreshToken,
});
return useStomp ? stomp : upbit;
}
+3 -1
View File
@@ -46,6 +46,8 @@ interface Options {
enabled?: boolean;
/** OBV 누적 정확도 — 더 많은 초기 봉 로드 */
deepObvHistory?: boolean;
/** 화면 새로고침 — REST 초기 로드 재실행 */
refreshToken?: number;
}
interface Return {
@@ -141,7 +143,7 @@ export function useStompChartData(
setIsLoading(false);
});
return () => { cancelled = true; };
}, [market, candleType, timeframe, opts.enabled, opts.deepObvHistory]);
}, [market, candleType, timeframe, opts.enabled, opts.deepObvHistory, opts.refreshToken]);
const syncBarsToReactIfNeeded = useCallback((marketId: string) => {
if (!bootstrapFromStompRef.current) return;
+10 -3
View File
@@ -28,6 +28,8 @@ interface UpbitDataOptions {
deepObvHistory?: boolean;
/** 직접 지정할 페치 봉 수 (deepObvHistory·WARMUP_COUNT 무시) */
fetchCount?: number;
/** 화면 새로고침 — REST 초기 로드 재실행 */
refreshToken?: number;
}
interface UseUpbitDataReturn {
@@ -73,6 +75,7 @@ export function useUpbitData(
// ── 1. REST API: 초기 캔들 로딩 (표시용 + 워밍업 합산) ─────────────────
// 이전 market 값을 기억해 종목 변경 시에만 캐시를 무효화한다.
const prevMarketRef = useRef<string>('');
const prevRefreshTokenRef = useRef(opts.refreshToken ?? 0);
useEffect(() => {
if (opts.enabled === false) return;
@@ -85,15 +88,19 @@ export function useUpbitData(
setLatestBar(null);
barsRef.current = [];
const refreshToken = opts.refreshToken ?? 0;
const bustCache = refreshToken > 0 && refreshToken !== prevRefreshTokenRef.current;
prevRefreshTokenRef.current = refreshToken;
// 종목(market)이 변경된 경우에는 캐시를 무효화해 신규 요청을 강제한다.
// 타임프레임만 바뀐 경우도 캐시 키가 다르므로 자동으로 새 요청이 발생한다.
const marketChanged = prevMarketRef.current !== '' && prevMarketRef.current !== market;
prevMarketRef.current = market;
if (bustCache) invalidateMarketCache(market);
const fetchCount = opts.fetchCount
?? (opts.deepObvHistory ? OBV_DEEP_HISTORY : DISPLAY_COUNT + WARMUP_COUNT);
fetchUpbitCandlesCached(market, timeframe, fetchCount, marketChanged)
fetchUpbitCandlesCached(market, timeframe, fetchCount, marketChanged || bustCache)
.then(newBars => {
if (cancelled) return;
barsRef.current = newBars;
@@ -109,7 +116,7 @@ export function useUpbitData(
});
return () => { cancelled = true; };
}, [market, timeframe, opts.enabled, opts.deepObvHistory, opts.fetchCount]);
}, [market, timeframe, opts.enabled, opts.deepObvHistory, opts.fetchCount, opts.refreshToken]);
// ── 2. WebSocket: 실시간 체결 구독 ────────────────────────────────────
const handleMessage = useCallback((msg: UpbitMessage) => {
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
import type { MenuPage } from '../components/TopMenuBar';
export const PAGE_REFRESH_EVENT = 'gc_page_refresh';
export type PageRefreshDetail = {
page: MenuPage;
ts: number;
};
export function dispatchPageRefresh(page: MenuPage): void {
window.dispatchEvent(new CustomEvent<PageRefreshDetail>(PAGE_REFRESH_EVENT, {
detail: { page, ts: Date.now() },
}));
}
/** 현재 메뉴(또는 지정 목록)에서 새로고침 이벤트 수신 */
export function usePageRefresh(
page: MenuPage | readonly MenuPage[],
onRefresh: () => void | Promise<void>,
): void {
const handlerRef = useRef(onRefresh);
handlerRef.current = onRefresh;
useEffect(() => {
const pages = Array.isArray(page) ? page : [page];
const listener = (e: Event) => {
const detail = (e as CustomEvent<PageRefreshDetail>).detail;
if (!detail || !pages.includes(detail.page)) return;
void handlerRef.current();
};
window.addEventListener(PAGE_REFRESH_EVENT, listener);
return () => window.removeEventListener(PAGE_REFRESH_EVENT, listener);
}, [page]);
}