mobile download
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(n?: number): string {
|
||||
if (n == null || !Number.isFinite(n)) return '—';
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
|
||||
function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
|
||||
if (!info?.available) return '';
|
||||
return info.downloadUrl?.trim() ?? '';
|
||||
}
|
||||
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const downloadUrl = useMemo(() => resolveDownloadUrl(info), [info]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadMobileAppReleaseInfo()
|
||||
.then(data => {
|
||||
if (!cancelled) setInfo(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadUrl) {
|
||||
setQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void QRCode.toDataURL(downloadUrl, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
color: { dark: '#111827', light: '#ffffff' },
|
||||
}).then(url => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setQrDataUrl(null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [downloadUrl]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = info?.fileName ?? 'goldenchart-android.apk';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableModalFrame
|
||||
onClose={onClose}
|
||||
title="Mobile App"
|
||||
titleKo="GoldenChart 앱 설치"
|
||||
badge="Android"
|
||||
width={440}
|
||||
dialogClassName="sp-modal app-download-modal"
|
||||
>
|
||||
<div className="app-download-body">
|
||||
{loading && <p className="app-download-muted">앱 정보 불러오는 중…</p>}
|
||||
{!loading && error && <p className="app-download-error">{error}</p>}
|
||||
{!loading && !error && !info?.available && (
|
||||
<div className="app-download-empty">
|
||||
<div className="app-download-empty-icon" aria-hidden>📱</div>
|
||||
<h3>설치 파일 준비 중</h3>
|
||||
<p>
|
||||
Android APK가 아직 서버에 업로드되지 않았습니다.
|
||||
<br />
|
||||
<code>data/mobile-releases/goldenchart-android.apk</code> 경로에 빌드 파일을 배치하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && info?.available && (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart</h3>
|
||||
<p className="app-download-meta">
|
||||
{info.version && <span>v{info.version}</span>}
|
||||
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
||||
{info.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-download-qr-block">
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
|
||||
) : (
|
||||
<div className="app-download-qr app-download-qr--placeholder">QR 생성 중…</div>
|
||||
)}
|
||||
<div className="app-download-qr-help">
|
||||
<strong>휴대폰 카메라로 QR 스캔</strong>
|
||||
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
||||
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
|
||||
Android APK 다운로드
|
||||
</button>
|
||||
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법 (Android)</summary>
|
||||
<ol>
|
||||
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
||||
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
||||
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
||||
<li>설치 후 앱에서 API 서버 URL을 설정하세요 (설정 › 네트워크).</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<p className="app-download-ios-note">
|
||||
iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDownloadModal;
|
||||
@@ -13,6 +13,7 @@ interface NotifyTopMenuBarProps {
|
||||
theme: Theme;
|
||||
onPage: (page: MenuPage) => void;
|
||||
onTheme: () => void;
|
||||
onOpenAppDownload?: () => void;
|
||||
authUser?: AuthSession | null;
|
||||
onLoginClick?: () => void;
|
||||
onLogout?: () => void;
|
||||
@@ -47,8 +48,12 @@ export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
|
||||
|
||||
import Toolbar, { type ToolbarProps } from './Toolbar';
|
||||
|
||||
export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: () => void }> = ({
|
||||
export const ChartToolbarNotify: React.FC<ToolbarProps & {
|
||||
onOpenNotifyList: () => void;
|
||||
onOpenAppDownload?: () => void;
|
||||
}> = ({
|
||||
onOpenNotifyList,
|
||||
onOpenAppDownload,
|
||||
...toolbarProps
|
||||
}) => {
|
||||
const { unreadCount } = useTradeNotification();
|
||||
@@ -57,6 +62,7 @@ export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: ()
|
||||
{...toolbarProps}
|
||||
tradeNotifyUnread={unreadCount}
|
||||
onOpenTradeNotifications={onOpenNotifyList}
|
||||
onOpenAppDownload={onOpenAppDownload}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import type { Theme } from '../types/index';
|
||||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import { loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
|
||||
import type { LogicNode } from '../utils/strategyTypes';
|
||||
import {
|
||||
buildStrategyEditorDefFromSettings,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
type EditorConditionState,
|
||||
} from '../utils/strategyConditionSerde';
|
||||
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
|
||||
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
|
||||
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
defaultStartMeta,
|
||||
@@ -48,10 +49,10 @@ import {
|
||||
} from '../utils/strategyPaletteStorage';
|
||||
import {
|
||||
emptySignalFlowLayout,
|
||||
loadStrategyFlowLayout,
|
||||
migrateStrategyFlowLayout,
|
||||
saveStrategyFlowLayout,
|
||||
deleteStrategyFlowLayout,
|
||||
buildStrategyFlowLayoutStore,
|
||||
normalizeStrategyFlowLayout,
|
||||
readLegacyFlowLayoutFromLocalStorage,
|
||||
clearLegacyFlowLayoutFromLocalStorage,
|
||||
type SignalFlowLayoutSnapshot,
|
||||
type FlowLayoutChangePayload,
|
||||
type StrategyFlowLayoutStore,
|
||||
@@ -97,21 +98,6 @@ interface Props {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
|
||||
const stored = loadStrategyFlowLayout(strategyKey);
|
||||
const snap = stored?.[tab];
|
||||
if (!snap) return emptySignalFlowLayout();
|
||||
return {
|
||||
positions: snap.positions ?? {},
|
||||
edgeHandles: snap.edgeHandles ?? {},
|
||||
orphans: snap.orphans ?? [],
|
||||
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: snap.extraStartIds ?? [],
|
||||
extraRoots: snap.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
|
||||
};
|
||||
}
|
||||
|
||||
function toEditorState(
|
||||
root: LogicNode | null,
|
||||
layout: Pick<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
|
||||
@@ -136,6 +122,28 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
function applyFlowLayoutFromStore(
|
||||
stored: StrategyFlowLayoutStore | null,
|
||||
setBuyLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
|
||||
setSellLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
|
||||
setBuyOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
|
||||
setSellOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
|
||||
) {
|
||||
if (stored) {
|
||||
setBuyLayout(normalizeTabLayoutState(stored.buy));
|
||||
setSellLayout(normalizeTabLayoutState(stored.sell));
|
||||
setBuyOrphans(stored.buy.orphans ?? []);
|
||||
setSellOrphans(stored.sell.orphans ?? []);
|
||||
} else {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
}
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme }: Props) {
|
||||
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
|
||||
const DEF = useMemo(
|
||||
@@ -164,44 +172,32 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const [descOpen, setDescOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const initialDraftBuy = readTabLayout('draft', 'buy');
|
||||
const initialDraftSell = readTabLayout('draft', 'sell');
|
||||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
|
||||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
|
||||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
|
||||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
|
||||
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
const [saveToast, setSaveToast] = useState(false);
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||||
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const terminalHeightRef = useRef(terminalHeight);
|
||||
const [buyLayout, setBuyLayout] = useState(() => ({
|
||||
positions: initialDraftBuy.positions,
|
||||
edgeHandles: initialDraftBuy.edgeHandles,
|
||||
startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: initialDraftBuy.extraStartIds ?? [],
|
||||
extraRoots: initialDraftBuy.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
|
||||
}));
|
||||
const [sellLayout, setSellLayout] = useState(() => ({
|
||||
positions: initialDraftSell.positions,
|
||||
edgeHandles: initialDraftSell.edgeHandles,
|
||||
startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: initialDraftSell.extraStartIds ?? [],
|
||||
extraRoots: initialDraftSell.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
|
||||
}));
|
||||
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
const buyLayoutRef = useRef(buyLayout);
|
||||
const sellLayoutRef = useRef(sellLayout);
|
||||
const buyConditionRef = useRef(buyCondition);
|
||||
const sellConditionRef = useRef(sellCondition);
|
||||
const buyOrphansRef = useRef(buyOrphans);
|
||||
const sellOrphansRef = useRef(sellOrphans);
|
||||
buyLayoutRef.current = buyLayout;
|
||||
sellLayoutRef.current = sellLayout;
|
||||
buyConditionRef.current = buyCondition;
|
||||
sellConditionRef.current = sellCondition;
|
||||
const timeframeAutosaveRef = useRef<number | null>(null);
|
||||
buyOrphansRef.current = buyOrphans;
|
||||
sellOrphansRef.current = sellOrphans;
|
||||
const layoutRevisionRef = useRef(0);
|
||||
const persistLayoutTimerRef = useRef<number | null>(null);
|
||||
const strategyAutosaveTimerRef = useRef<number | null>(null);
|
||||
const layoutPersistReadyRef = useRef(false);
|
||||
const STRATEGY_AUTOSAVE_MS = 350;
|
||||
leftWidthRef.current = leftWidth;
|
||||
terminalHeightRef.current = terminalHeight;
|
||||
|
||||
@@ -268,20 +264,67 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||||
|
||||
const persistFlowLayout = useCallback((strategyKey: string) => {
|
||||
saveStrategyFlowLayout(strategyKey, {
|
||||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||||
});
|
||||
}, [buyOrphans, sellOrphans]);
|
||||
const buildCurrentFlowLayout = useCallback(
|
||||
(): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore(
|
||||
buyLayoutRef.current,
|
||||
sellLayoutRef.current,
|
||||
buyOrphansRef.current,
|
||||
sellOrphansRef.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
|
||||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||||
persistLayoutTimerRef.current = window.setTimeout(() => {
|
||||
persistLayoutTimerRef.current = null;
|
||||
persistFlowLayout(strategyKey);
|
||||
}, 150);
|
||||
}, [persistFlowLayout]);
|
||||
const persistStrategyToDb = useCallback(async () => {
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||||
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
if (!encodedBuy && !encodedSell) return;
|
||||
const flowLayout = buildCurrentFlowLayout();
|
||||
const saved = await persistStrategyEditorState(
|
||||
selectedId,
|
||||
stratName,
|
||||
stratDesc,
|
||||
aligned.buy,
|
||||
aligned.sell,
|
||||
flowLayout,
|
||||
);
|
||||
setStrategies(prev => prev.map(s => s.id === selectedId
|
||||
? {
|
||||
...s,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
updatedAt: saved?.updatedAt ?? s.updatedAt,
|
||||
}
|
||||
: s));
|
||||
}, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]);
|
||||
|
||||
const flushStrategyPersist = useCallback(() => {
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
}
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
void persistStrategyToDb().catch(err => {
|
||||
console.warn('[StrategyEditor] 전략 DB 저장 실패:', err);
|
||||
});
|
||||
}, [selectedId, stratName, persistStrategyToDb]);
|
||||
|
||||
const scheduleStrategyPersist = useCallback(() => {
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
}
|
||||
strategyAutosaveTimerRef.current = window.setTimeout(() => {
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
void persistStrategyToDb().catch(err => {
|
||||
console.warn('[StrategyEditor] 전략 자동저장 실패:', err);
|
||||
});
|
||||
}, STRATEGY_AUTOSAVE_MS);
|
||||
}, [selectedId, stratName, persistStrategyToDb]);
|
||||
|
||||
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
|
||||
layoutRevisionRef.current += 1;
|
||||
@@ -318,50 +361,11 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
|
||||
}));
|
||||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
|
||||
if (selectedId != null && stratName.trim()) scheduleStrategyPersist();
|
||||
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]);
|
||||
|
||||
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
|
||||
bumpLayoutSeed(strategyKey, tab);
|
||||
}, [bumpLayoutSeed]);
|
||||
|
||||
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const stored = loadStrategyFlowLayout(strategyKey);
|
||||
if (stored) {
|
||||
const nextBuy = {
|
||||
positions: stored.buy.positions ?? {},
|
||||
edgeHandles: stored.buy.edgeHandles ?? {},
|
||||
startMeta: stored.buy.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: stored.buy.extraStartIds ?? [],
|
||||
extraRoots: stored.buy.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
|
||||
};
|
||||
const nextSell = {
|
||||
positions: stored.sell.positions ?? {},
|
||||
edgeHandles: stored.sell.edgeHandles ?? {},
|
||||
startMeta: stored.sell.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: stored.sell.extraStartIds ?? [],
|
||||
extraRoots: stored.sell.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
|
||||
};
|
||||
setBuyLayout(nextBuy);
|
||||
setSellLayout(nextSell);
|
||||
setBuyOrphans(stored.buy.orphans ?? []);
|
||||
setSellOrphans(stored.sell.orphans ?? []);
|
||||
} else {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
}
|
||||
applyFlowLayoutFromStore(null, setBuyLayout, setSellLayout, setBuyOrphans, setSellOrphans);
|
||||
bumpLayoutSeed(strategyKey, tab);
|
||||
}, [bumpLayoutSeed]);
|
||||
|
||||
@@ -372,24 +376,34 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
};
|
||||
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
|
||||
else setSellLayout(prev => ({ ...prev, ...patch }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [scheduleStrategyPersist]);
|
||||
|
||||
const handleRootChange = useCallback((root: LogicNode | null) => {
|
||||
setCurrentRoot(root);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentRoot, scheduleStrategyPersist]);
|
||||
|
||||
const handleOrphansChange = useCallback((orphans: LogicNode[]) => {
|
||||
setCurrentOrphans(orphans);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentOrphans, scheduleStrategyPersist]);
|
||||
|
||||
const handleStartMetaChange = useCallback((meta: Record<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentLayout, scheduleStrategyPersist]);
|
||||
|
||||
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
|
||||
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
scheduleStrategyPersist();
|
||||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
|
||||
}, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]);
|
||||
|
||||
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentLayout, scheduleStrategyPersist]);
|
||||
|
||||
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
|
||||
setCurrentRoot(next.root);
|
||||
@@ -400,24 +414,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
extraRoots: next.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
|
||||
}));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const scheduleTimeframePersist = useCallback(() => {
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
|
||||
const id = selectedId;
|
||||
const name = stratName;
|
||||
const desc = stratDesc;
|
||||
timeframeAutosaveRef.current = window.setTimeout(() => {
|
||||
timeframeAutosaveRef.current = null;
|
||||
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||||
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||||
void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
|
||||
console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
|
||||
});
|
||||
}, 700);
|
||||
}, [selectedId, stratName, stratDesc]);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
|
||||
|
||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
if (startId === START_NODE_ID) {
|
||||
@@ -438,20 +436,16 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
extraRoots: synced.sell.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
|
||||
}));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
scheduleTimeframePersist();
|
||||
scheduleStrategyPersist();
|
||||
return;
|
||||
}
|
||||
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
|
||||
scheduleTimeframePersist();
|
||||
}, [
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
handleEditorStateChange,
|
||||
layoutStrategyKey,
|
||||
schedulePersistFlowLayout,
|
||||
scheduleTimeframePersist,
|
||||
scheduleStrategyPersist,
|
||||
signalTab,
|
||||
]);
|
||||
|
||||
@@ -461,18 +455,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
persistFlowLayout(layoutStrategyKey);
|
||||
flushStrategyPersist();
|
||||
layoutRevisionRef.current += 1;
|
||||
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||||
setSignalTab(tab);
|
||||
setSelectedNodeId(null);
|
||||
}, [layoutStrategyKey, persistFlowLayout, editorMode]);
|
||||
}, [layoutStrategyKey, flushStrategyPersist, editorMode]);
|
||||
|
||||
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
|
||||
if (mode === editorMode) return;
|
||||
if (editorMode === 'graph') {
|
||||
layoutFlushRef.current?.();
|
||||
persistFlowLayout(layoutStrategyKey);
|
||||
flushStrategyPersist();
|
||||
}
|
||||
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
|
||||
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
|
||||
@@ -480,7 +474,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
setEditorMode(mode);
|
||||
saveEditorMode(mode);
|
||||
setSelectedNodeId(null);
|
||||
}, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
|
||||
}, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
|
||||
|
||||
const layoutSeed = useMemo((): FlowLayoutSeed => {
|
||||
const source = signalTab === 'buy' ? buyLayout : sellLayout;
|
||||
@@ -491,17 +485,29 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
};
|
||||
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||||
}, []);
|
||||
const persistStrategyToDbRef = useRef(persistStrategyToDb);
|
||||
persistStrategyToDbRef.current = persistStrategyToDb;
|
||||
useEffect(() => {
|
||||
const id = selectedId;
|
||||
const name = stratName;
|
||||
return () => {
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
}
|
||||
if (id != null && name.trim()) {
|
||||
void persistStrategyToDbRef.current().catch(() => { /* unmount flush */ });
|
||||
}
|
||||
};
|
||||
}, [selectedId, stratName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutPersistReadyRef.current) {
|
||||
layoutPersistReadyRef.current = true;
|
||||
return;
|
||||
}
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [buyOrphans, sellOrphans, scheduleStrategyPersist]);
|
||||
|
||||
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
|
||||
|
||||
@@ -514,6 +520,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
description: s.description,
|
||||
buyCondition: s.buyCondition as LogicNode | null ?? null,
|
||||
sellCondition: s.sellCondition as LogicNode | null ?? null,
|
||||
flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined,
|
||||
enabled: s.enabled ?? true,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
@@ -553,7 +560,10 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const handleSelectStrategy = (s: StrategyDto) => {
|
||||
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
|
||||
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
||||
const stored = loadStrategyFlowLayout(String(s.id));
|
||||
let stored = normalizeStrategyFlowLayout(s.flowLayout);
|
||||
if (!stored) {
|
||||
stored = readLegacyFlowLayoutFromLocalStorage(String(s.id));
|
||||
}
|
||||
|
||||
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
|
||||
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
|
||||
@@ -600,6 +610,58 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
bumpLayoutSeed(String(s.id), signalTab);
|
||||
|
||||
if (!s.flowLayout && stored) {
|
||||
void (async () => {
|
||||
try {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(
|
||||
toEditorState(buySynced.root, {
|
||||
startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta),
|
||||
extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds,
|
||||
extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp),
|
||||
}),
|
||||
toEditorState(sellSynced.root, {
|
||||
startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta),
|
||||
extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds,
|
||||
extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp),
|
||||
}),
|
||||
);
|
||||
await saveStrategy({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description ?? '',
|
||||
buyCondition: encodeConditionForSave(aligned.buy),
|
||||
sellCondition: encodeConditionForSave(aligned.sell),
|
||||
flowLayout: stored,
|
||||
enabled: s.enabled ?? true,
|
||||
});
|
||||
clearLegacyFlowLayoutFromLocalStorage(String(s.id));
|
||||
setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준)
|
||||
void (async () => {
|
||||
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout });
|
||||
if (!synced) return;
|
||||
try {
|
||||
const fresh = await loadStrategy(s.id);
|
||||
if (!fresh) return;
|
||||
const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null);
|
||||
const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null);
|
||||
const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy');
|
||||
const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell');
|
||||
setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root);
|
||||
setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
@@ -620,39 +682,59 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}
|
||||
setSaveNameError(false);
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
}
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const flowLayout = buildCurrentFlowLayout();
|
||||
const payload: ApiStrategyDto = {
|
||||
id: selectedId ?? undefined,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
};
|
||||
const saved = await saveStrategy(payload);
|
||||
const now = new Date().toISOString();
|
||||
const dbId = saved?.id ?? selectedId ?? Date.now();
|
||||
const wasDraft = selectedId == null;
|
||||
setStrategies(prev => {
|
||||
const existing = prev.find(s => s.id === selectedId);
|
||||
if (existing && selectedId) {
|
||||
return prev.map(s => s.id === selectedId
|
||||
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
|
||||
? {
|
||||
...s,
|
||||
id: dbId,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
updatedAt: saved?.updatedAt ?? now,
|
||||
}
|
||||
: s);
|
||||
}
|
||||
setSelectedId(dbId);
|
||||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||||
return [...prev, {
|
||||
id: dbId,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
createdAt: saved?.createdAt ?? now,
|
||||
updatedAt: saved?.updatedAt ?? now,
|
||||
}];
|
||||
});
|
||||
if (!selectedId) setSelectedId(dbId);
|
||||
if (wasDraft) {
|
||||
migrateStrategyFlowLayout('draft', String(dbId));
|
||||
}
|
||||
persistFlowLayout(String(dbId));
|
||||
bumpLayoutSeed(String(dbId), signalTab);
|
||||
setSaveOpen(false);
|
||||
setSaveToast(true);
|
||||
@@ -668,7 +750,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteId) return;
|
||||
try { await deleteStrategy(deleteId); } catch { /* local */ }
|
||||
deleteStrategyFlowLayout(String(deleteId));
|
||||
clearLegacyFlowLayoutFromLocalStorage(String(deleteId));
|
||||
setStrategies(prev => prev.filter(s => s.id !== deleteId));
|
||||
if (selectedId === deleteId) handleNew();
|
||||
setDeleteOpen(false);
|
||||
@@ -696,7 +778,6 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
});
|
||||
setBuyOrphans(layout.buy.orphans ?? []);
|
||||
setSellOrphans(layout.sell.orphans ?? []);
|
||||
saveStrategyFlowLayout('draft', layout);
|
||||
} else {
|
||||
resetFlowLayout('draft', tab);
|
||||
}
|
||||
@@ -773,7 +854,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}
|
||||
const items = strategies.map(s => strategyDtoToListExportItem(
|
||||
s,
|
||||
loadStrategyFlowLayout(String(s.id)),
|
||||
s.flowLayout ?? null,
|
||||
));
|
||||
downloadStrategyJson(
|
||||
`전략목록_${new Date().toISOString().slice(0, 10)}`,
|
||||
@@ -794,7 +875,6 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const baseId = Date.now();
|
||||
const imported = result.data.strategies.map((item, index) => {
|
||||
const id = baseId + index;
|
||||
if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout);
|
||||
return listImportItemToStrategyDto(item, id);
|
||||
});
|
||||
setStrategies(prev => [...prev, ...imported]);
|
||||
@@ -819,7 +899,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
|
||||
scheduleStrategyPersist();
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
|
||||
|
||||
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||||
const composite = item.kind === 'composite';
|
||||
@@ -827,7 +908,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const root = currentRoot;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
|
||||
scheduleStrategyPersist();
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
|
||||
|
||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||
|
||||
@@ -846,9 +928,10 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
resetFlowLayout(layoutStrategyKey, targetTab);
|
||||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||||
scheduleStrategyPersist();
|
||||
|
||||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]);
|
||||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
|
||||
|
||||
const operators = [
|
||||
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
|
||||
@@ -1068,10 +1151,10 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
theme={theme}
|
||||
root={currentRoot}
|
||||
orphans={currentOrphans}
|
||||
onOrphansChange={setCurrentOrphans}
|
||||
onOrphansChange={handleOrphansChange}
|
||||
def={DEF}
|
||||
signalTab={signalTab}
|
||||
onChange={setCurrentRoot}
|
||||
onChange={handleRootChange}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
layoutSeed={layoutSeed}
|
||||
@@ -1097,14 +1180,14 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
if (!selectedNodeId) return;
|
||||
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
||||
if (inOrphans) {
|
||||
setCurrentOrphans(currentOrphans.map(o => (
|
||||
handleOrphansChange(currentOrphans.map(o => (
|
||||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
|
||||
if (findLogicNode(currentRoot, [], selectedNodeId)) {
|
||||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||
handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||
return;
|
||||
}
|
||||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||||
@@ -1131,7 +1214,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
onEditorStateChange={handleEditorStateChange}
|
||||
onAddStart={handleAddStartSection}
|
||||
orphans={currentOrphans}
|
||||
onOrphansChange={setCurrentOrphans}
|
||||
onOrphansChange={handleOrphansChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -66,11 +66,8 @@ interface ValidationResult {
|
||||
let _cnt = 0;
|
||||
const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||||
|
||||
const STORAGE_KEY = 'gc_strat_v2';
|
||||
const loadStratsLocal = (): StrategyDto[] => {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
|
||||
};
|
||||
const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
const loadStratsLocal = (): StrategyDto[] => [];
|
||||
const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ };
|
||||
|
||||
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
|
||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||
|
||||
@@ -40,30 +40,18 @@ import LayoutPicker from './LayoutPicker';
|
||||
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
|
||||
import { loadStrategies } from '../utils/backendApi';
|
||||
|
||||
const LOCAL_BT_KEY = 'gc_strat_v2';
|
||||
|
||||
interface BtStrategy { id?: number; name: string; source: 'db' | 'local'; buyCondition?: unknown; sellCondition?: unknown; }
|
||||
interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; }
|
||||
|
||||
function loadBtStrategies(): Promise<BtStrategy[]> {
|
||||
return loadStrategies()
|
||||
.then(list => {
|
||||
if (list && list.length > 0) {
|
||||
return list.map(s => ({ id: s.id, name: s.name, source: 'db' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
|
||||
}
|
||||
// localStorage 폴백
|
||||
try {
|
||||
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
|
||||
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
|
||||
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
|
||||
} catch { return []; }
|
||||
})
|
||||
.catch(() => {
|
||||
try {
|
||||
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
|
||||
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
|
||||
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
|
||||
} catch { return []; }
|
||||
});
|
||||
.then(list => (list ?? []).map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
source: 'db' as const,
|
||||
buyCondition: s.buyCondition,
|
||||
sellCondition: s.sellCondition,
|
||||
})))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export interface ToolbarProps {
|
||||
@@ -98,6 +86,8 @@ export interface ToolbarProps {
|
||||
/** 매매 시그널 알림 (미확인 개수) */
|
||||
tradeNotifyUnread?: number;
|
||||
onOpenTradeNotifications?: () => void;
|
||||
/** 모바일 앱 다운로드 */
|
||||
onOpenAppDownload?: () => void;
|
||||
/** 자석모드 현재 상태 */
|
||||
magnetMode?: 'off' | 'weak' | 'strong';
|
||||
/** 자석모드 토글 (off↔strong) */
|
||||
@@ -233,6 +223,14 @@ const IcBell = () => (
|
||||
<circle cx="7.5" cy="13" r="0.8" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
);
|
||||
/** 모바일 앱 다운로드 */
|
||||
const IcAppDownload = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="1.5" width="7" height="12" rx="1.5"/>
|
||||
<line x1="7.5" y1="9" x2="7.5" y2="12"/>
|
||||
<polyline points="5.5,10.5 7.5,12.5 9.5,10.5"/>
|
||||
</svg>
|
||||
);
|
||||
/** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */
|
||||
const IcTradeSignal = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -785,7 +783,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onIndicatorOrderChange, onRemoveByType,
|
||||
onAutoFib, onClearFib, onScreenshot,
|
||||
onToggleStats, onToggleWatch, onToggleAlert,
|
||||
tradeNotifyUnread = 0, onOpenTradeNotifications,
|
||||
tradeNotifyUnread = 0, onOpenTradeNotifications, onOpenAppDownload,
|
||||
magnetMode = 'off', onToggleMagnet,
|
||||
onFullscreen, onUndo, onRedo, canUndo, canRedo, onClearDrawings,
|
||||
onToggleGrid,
|
||||
@@ -1091,7 +1089,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<li key={i} className="bt-drop-item">
|
||||
<span className="bt-drop-name">
|
||||
{s.name}
|
||||
{s.source === 'local' && <span className="bt-drop-local-badge">로컬</span>}
|
||||
</span>
|
||||
<button
|
||||
className={`bt-drop-run${isRunning ? ' running' : ''}`}
|
||||
@@ -1283,6 +1280,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onOpenAppDownload && (
|
||||
<button
|
||||
className="tv-icon-btn"
|
||||
onClick={onOpenAppDownload}
|
||||
title="GoldenChart 모바일 앱 다운로드"
|
||||
aria-label="앱 다운로드"
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
)}
|
||||
{onOpenTradeNotifications && (
|
||||
<span className="tv-trade-notify-wrap">
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,8 @@ interface TopMenuBarProps {
|
||||
/** 미확인 매매 시그널 알림 수 */
|
||||
tradeNotifyUnread?: number;
|
||||
onOpenNotifications?: () => void;
|
||||
/** 모바일 앱 다운로드 모달 */
|
||||
onOpenAppDownload?: () => void;
|
||||
/** 우측에 떠 있는 알림 팝업(토스트) 개수 */
|
||||
tradeNotifyToastCount?: number;
|
||||
/** 모든 알림 팝업 닫기 */
|
||||
@@ -79,6 +81,14 @@ const IcSettings = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcAppDownload = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5"/>
|
||||
<line x1="8" y1="9" x2="8" y2="12.5"/>
|
||||
<polyline points="6,10.5 8,12.5 10,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"/>
|
||||
@@ -160,6 +170,7 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
activePage, theme, onPage, onTheme,
|
||||
tradeNotifyUnread = 0,
|
||||
onOpenNotifications,
|
||||
onOpenAppDownload,
|
||||
tradeNotifyToastCount = 0,
|
||||
onDismissAllNotifyPopups,
|
||||
tradeAlertPopupPosition = 'right',
|
||||
@@ -209,6 +220,17 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
|
||||
{/* 우측 영역 */}
|
||||
<div className="tmb-right">
|
||||
{onOpenAppDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-app-download-btn"
|
||||
onClick={onOpenAppDownload}
|
||||
title="GoldenChart 모바일 앱 다운로드"
|
||||
aria-label="앱 다운로드"
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
)}
|
||||
{onOpenNotifications && showNotifications && (
|
||||
<div className="tmb-notify-group">
|
||||
<button
|
||||
|
||||
@@ -221,8 +221,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevTheme = useRef<Theme>(theme);
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
const indicatorsRef = useRef(indicators);
|
||||
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resizeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const recoveryTimerRef = useRef<number | null>(null);
|
||||
const resizeDebounceRef = useRef<number | null>(null);
|
||||
const chartVisibleRef = useRef(chartVisible);
|
||||
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
|
||||
@@ -33,16 +33,6 @@ import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
|
||||
import '../styles/trendSearchDashboard.css';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
const DISPLAY_MODE_KEY = 'tsd-display-mode';
|
||||
|
||||
function loadDisplayMode(): TrendSearchDisplayMode {
|
||||
try {
|
||||
const v = localStorage.getItem(DISPLAY_MODE_KEY);
|
||||
if (v === 'chart' || v === 'summary') return v;
|
||||
} catch { /* ignore */ }
|
||||
return 'summary';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
@@ -68,7 +58,9 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
|
||||
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
|
||||
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(
|
||||
() => tsSettings.displayMode ?? 'summary',
|
||||
);
|
||||
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
|
||||
const filtersRef = useRef(filters);
|
||||
@@ -84,6 +76,7 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
if (lastSyncedSettingsSigRef.current === tsSettingsSig) return;
|
||||
lastSyncedSettingsSigRef.current = tsSettingsSig;
|
||||
setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings));
|
||||
if (tsSettings.displayMode) setDisplayMode(tsSettings.displayMode);
|
||||
}, [appSettingsLoaded, tsSettingsSig, tsSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,8 +161,10 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
}, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
|
||||
}, [displayMode]);
|
||||
if (!appSettingsLoaded) return;
|
||||
if (tsSettings.displayMode === displayMode) return;
|
||||
persistTrendSettings({ displayMode });
|
||||
}, [displayMode, appSettingsLoaded, tsSettings.displayMode, persistTrendSettings]);
|
||||
|
||||
const handleSelect = useCallback(async (row: TrendSearchResultDto) => {
|
||||
setSelectedMarket(row.market);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { getUiPreferences, patchUiPreferences } from '../../utils/uiPreferencesDb';
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -49,9 +50,9 @@ export function usePanelResize(
|
||||
|
||||
export function readStoredSize(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const n = Number(raw);
|
||||
const raw = getUiPreferences().panels?.[key];
|
||||
if (raw == null) return fallback;
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
@@ -59,22 +60,20 @@ export function readStoredSize(key: string, fallback: number): number {
|
||||
}
|
||||
|
||||
export function storeSize(key: string, value: number): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(Math.round(value)));
|
||||
} catch { /* ignore */ }
|
||||
const panels = { ...getUiPreferences().panels, [key]: Math.round(value) };
|
||||
patchUiPreferences({ panels });
|
||||
}
|
||||
|
||||
export function readStoredBool(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === '1' || raw === 'true') return true;
|
||||
if (raw === '0' || raw === 'false') return false;
|
||||
const raw = getUiPreferences().panels?.[key];
|
||||
if (raw === true || raw === '1' || raw === 'true') return true;
|
||||
if (raw === false || raw === '0' || raw === 'false') return false;
|
||||
} catch { /* ignore */ }
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function storeBool(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, value ? '1' : '0');
|
||||
} catch { /* ignore */ }
|
||||
const panels = { ...getUiPreferences().panels, [key]: value };
|
||||
patchUiPreferences({ panels });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user