가상매매 시간봉 제거
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구)
|
||||
* 모바일 로그인 — SplashScreen UI + 느린 서버(exdev) 대응
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { loginUser, type LoginResponse } from '../lib/shared';
|
||||
import {
|
||||
loginUser,
|
||||
initStorage,
|
||||
type LoginResponse,
|
||||
} from '../lib/shared';
|
||||
import { ensureMobileApiBase } from '../lib/ensureMobileApiBase';
|
||||
import { useAppVersion } from '../hooks/useAppVersion';
|
||||
import '@frontend/styles/splashScreen.css';
|
||||
|
||||
const DEFAULT_USERNAME = 'admin';
|
||||
const DEFAULT_PASSWORD = 'admin';
|
||||
const APP_VERSION = '1.2';
|
||||
|
||||
interface Props {
|
||||
onLoginSuccess: (res: LoginResponse) => void;
|
||||
@@ -15,23 +20,38 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
const appVersion = useAppVersion();
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [slowHint, setSlowHint] = useState(false);
|
||||
const [storageReady, setStorageReady] = useState(false);
|
||||
const [apiBase, setApiBase] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await initStorage();
|
||||
setApiBase(ensureMobileApiBase());
|
||||
setStorageReady(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setSlowHint(false);
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(() => setSlowHint(true), 5000);
|
||||
const t = window.setTimeout(() => setSlowHint(true), 4000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [loading]);
|
||||
|
||||
const submitLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!storageReady) {
|
||||
setError('앱 초기화 중입니다. 잠시 후 다시 시도하세요.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -60,7 +80,7 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
disabled={loading}
|
||||
disabled={loading || !storageReady}
|
||||
placeholder="ID"
|
||||
/>
|
||||
<input
|
||||
@@ -69,22 +89,29 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
disabled={loading}
|
||||
disabled={loading || !storageReady}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{error && <p className="splash-error">{error}</p>}
|
||||
{slowHint && loading && !error && (
|
||||
<p className="splash-error" style={{ opacity: 0.85 }}>
|
||||
서버 응답이 느립니다. Wi‑Fi·모바일 데이터를 확인해 주세요…
|
||||
exdev 서버가 느립니다. 첫 로그인은 <strong>최대 3분</strong> 걸릴 수 있습니다.
|
||||
버튼이 「로그인 중…」인 동안 기다려 주세요.
|
||||
<br />
|
||||
<span style={{ fontSize: 11 }}>API: {apiBase || '…'}</span>
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="splash-login-btn" disabled={loading}>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
<button
|
||||
type="submit"
|
||||
className="splash-login-btn"
|
||||
disabled={loading || !storageReady}
|
||||
>
|
||||
{!storageReady ? '준비 중…' : loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="splash-version">
|
||||
버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot
|
||||
APK {appVersion} · API {apiBase.replace(/^https?:\/\//, '')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
||||
import { initFcmPush, checkPushPermission } from '../../services/fcm';
|
||||
import { API_BASE } from '../../lib/shared';
|
||||
import { API_BASE, normalizeApiBaseUrl, PRODUCTION_API_BASE } from '../../lib/shared';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { authUser, guestMode, sessionKey, handleLogout } = useAuth();
|
||||
@@ -19,11 +18,11 @@ export default function SettingsScreen() {
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
const [apiUrl, setApiUrl] = useState(API_BASE);
|
||||
const [fcmAvailable, setFcmAvailable] = useState(false);
|
||||
const [pushPerm, setPushPerm] = useState<string>('prompt');
|
||||
const [pushPerm, setPushPerm] = useState<string>('—');
|
||||
const [fcmBusy, setFcmBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
||||
void checkPushPermission().then(setPushPerm);
|
||||
}, []);
|
||||
|
||||
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
|
||||
@@ -133,12 +132,17 @@ export default function SettingsScreen() {
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="FCM 푸시">
|
||||
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
|
||||
<SettingRow
|
||||
label="푸시 알림"
|
||||
description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정 · 1.9+ APK 필요'}
|
||||
>
|
||||
<Toggle
|
||||
checked={!!defaults.fcmPushEnabled}
|
||||
onChange={v => {
|
||||
patch({ fcmPushEnabled: v });
|
||||
if (v) void initFcmPush().then(() => checkPushPermission().then(setPushPerm));
|
||||
if (!v) {
|
||||
void import('../../services/fcm').then(m => m.unregisterFcmPush());
|
||||
}
|
||||
}}
|
||||
label="FCM 푸시"
|
||||
/>
|
||||
@@ -146,6 +150,56 @@ export default function SettingsScreen() {
|
||||
<SettingRow label="권한 상태">
|
||||
<span className="text-muted">{pushPerm}</span>
|
||||
</SettingRow>
|
||||
{defaults.fcmPushEnabled && (
|
||||
<>
|
||||
<SettingRow label="① 알림 권한" description="허용 후 앱이 꺼지면 ②는 나중에">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '8px 12px', minHeight: 36 }}
|
||||
disabled={fcmBusy}
|
||||
onClick={() => {
|
||||
setFcmBusy(true);
|
||||
void (async () => {
|
||||
const fcm = await import('../../services/fcm');
|
||||
const ok = await fcm.requestNotificationPermissionOnly();
|
||||
setPushPerm(await fcm.checkPushPermission());
|
||||
if (!ok) {
|
||||
window.alert('알림 권한이 거부되었습니다.');
|
||||
}
|
||||
})().finally(() => setFcmBusy(false));
|
||||
}}
|
||||
>
|
||||
알림 권한 허용
|
||||
</button>
|
||||
</SettingRow>
|
||||
<SettingRow label="② FCM 등록" description="① 완료 후 누르세요 (APK 2.5+)">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '8px 12px', minHeight: 36 }}
|
||||
disabled={fcmBusy}
|
||||
onClick={() => {
|
||||
setFcmBusy(true);
|
||||
void (async () => {
|
||||
const fcm = await import('../../services/fcm');
|
||||
const ok = await fcm.registerFcmTokenOnly();
|
||||
setPushPerm(await fcm.checkPushPermission());
|
||||
if (!ok) {
|
||||
window.alert(
|
||||
'FCM 등록 실패.\n'
|
||||
+ '① 알림 권한 허용 후\n'
|
||||
+ '② APK 2.5 재설치',
|
||||
);
|
||||
}
|
||||
})().finally(() => setFcmBusy(false));
|
||||
}}
|
||||
>
|
||||
FCM 토큰 등록
|
||||
</button>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingRow label="테스트 발송">
|
||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void sendFcmTest()}>테스트</button>
|
||||
</SettingRow>
|
||||
@@ -160,7 +214,18 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="적용">
|
||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px' }} onClick={() => { setApiBase(apiUrl); window.location.reload(); }}>저장</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => {
|
||||
const next = normalizeApiBaseUrl(apiUrl) ?? PRODUCTION_API_BASE;
|
||||
setApiBase(next);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</SettingRow>
|
||||
<SettingRow label="Device ID">
|
||||
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
session: VirtualSessionConfig;
|
||||
strategies: StrategyDto[];
|
||||
snapshot?: VirtualIndicatorSnapshot;
|
||||
signalLoading?: boolean;
|
||||
liveConnected: boolean;
|
||||
onBack: () => void;
|
||||
onTrade: (side: TradeSide) => void;
|
||||
@@ -20,6 +21,7 @@ interface Props {
|
||||
export default function VirtualFocusScreen({
|
||||
target,
|
||||
snapshot,
|
||||
signalLoading,
|
||||
liveConnected,
|
||||
onBack,
|
||||
onTrade,
|
||||
@@ -72,6 +74,11 @@ export default function VirtualFocusScreen({
|
||||
</div>
|
||||
|
||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>조건 목록</h3>
|
||||
{signalLoading && !(snapshot?.rows?.length) && (
|
||||
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
|
||||
지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)
|
||||
</p>
|
||||
)}
|
||||
<div className="stack-list">
|
||||
{(snapshot?.rows ?? []).map(row => (
|
||||
<div key={row.id} className="card mobile-history-row">
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
globalStrategyId: number | null;
|
||||
strategies: StrategyDto[];
|
||||
snapshot?: VirtualIndicatorSnapshot;
|
||||
signalLoading?: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
onDetail: () => void;
|
||||
onTrade: (side: TradeSide) => void;
|
||||
@@ -23,6 +24,7 @@ export default function VirtualTargetListRow({
|
||||
globalStrategyId,
|
||||
strategies,
|
||||
snapshot,
|
||||
signalLoading,
|
||||
liveStatus,
|
||||
onDetail,
|
||||
onTrade,
|
||||
@@ -42,7 +44,10 @@ export default function VirtualTargetListRow({
|
||||
<span>{target.koreanName ?? target.market}</span>
|
||||
</div>
|
||||
<div className="text-muted mobile-list-row-meta">
|
||||
{target.market} · {strategyName} · {matchPct.toFixed(0)}%
|
||||
{target.market} · {strategyName}
|
||||
{signalLoading && !snapshot?.rows?.length
|
||||
? ' · 지표 수집 중…'
|
||||
: ` · ${matchPct.toFixed(0)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-list-row-pin">
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function VirtualTradingScreen() {
|
||||
trades,
|
||||
loading,
|
||||
snapshots,
|
||||
snapshotLoadingByMarket,
|
||||
liveStatusByMarket,
|
||||
handleStart,
|
||||
handleStop,
|
||||
@@ -61,6 +62,7 @@ export default function VirtualTradingScreen() {
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
snapshot={snap}
|
||||
signalLoading={snapshotLoadingByMarket[market]}
|
||||
liveConnected={liveConnected}
|
||||
onBack={goVirtualList}
|
||||
onTrade={side => goVirtualTrade(market, side)}
|
||||
@@ -175,6 +177,7 @@ export default function VirtualTradingScreen() {
|
||||
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
|
||||
const snapKey = `${t.market}:${strategyId ?? ''}`;
|
||||
const snap = snapshots[snapKey] ?? snapshots[t.market];
|
||||
const signalLoading = snapshotLoadingByMarket[t.market];
|
||||
return (
|
||||
<VirtualTargetListRow
|
||||
key={t.market}
|
||||
@@ -182,6 +185,7 @@ export default function VirtualTradingScreen() {
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
strategies={strategies}
|
||||
snapshot={snap}
|
||||
signalLoading={signalLoading}
|
||||
liveStatus={liveStatusByMarket[t.market]}
|
||||
onDetail={() => goVirtualDetail(t.market)}
|
||||
onTrade={side => goVirtualTrade(t.market, side)}
|
||||
|
||||
Reference in New Issue
Block a user