검증게시판 단계 추가

This commit is contained in:
Macbook
2026-05-28 16:24:14 +09:00
parent f64dc1e983
commit 7e3644cb62
375 changed files with 4539 additions and 251294 deletions
+8 -3
View File
@@ -43,7 +43,7 @@ function MainApp() {
const defaults = resolveAppDefaults(settings);
useEffect(() => {
if (!isLoaded || !defaults.fcmPushEnabled) return;
if (!isLoaded) return;
void initFcmPush({
onForeground: (_title, _body, data) => {
if (data?.market && data?.signalType) {
@@ -67,7 +67,7 @@ function MainApp() {
}
},
});
}, [isLoaded, defaults.fcmPushEnabled, addNotification, refreshHistory, setTab, openVirtualFocus]);
}, [isLoaded, addNotification, refreshHistory, setTab, openVirtualFocus]);
useEffect(() => {
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
@@ -105,6 +105,7 @@ function AppRoot() {
isAppEntered,
handleLoginSuccess,
handleGuestEnter,
sessionKey,
} = useAuth();
const [nativeReady, setNativeReady] = useState(false);
@@ -136,7 +137,11 @@ function AppRoot() {
return (
<NavigationProvider>
<TradeNotificationProvider soundEnabled popupEnabled={false}>
<TradeNotificationProvider
soundEnabled
popupEnabled={false}
settingsSessionKey={sessionKey}
>
<MainApp />
</TradeNotificationProvider>
</NavigationProvider>
+24 -4
View File
@@ -6,16 +6,20 @@ import React, {
useMemo,
useState,
} from 'react';
import { Capacitor } from '@capacitor/core';
import {
clearAuthSession,
fetchAuthMe,
getAuthSession,
initStorage,
refreshApiBaseFromStorage,
setAuthSession,
storageGetSync,
storageRemove,
type AuthSession,
type LoginResponse,
} from '../lib/shared';
import { invalidateAppSettingsCache } from '../hooks/useAppSettings';
import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings';
function normalizeRole(role: string): AuthSession['role'] {
return role === 'ADMIN' ? 'ADMIN' : 'USER';
@@ -53,10 +57,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
let cancelled = false;
void (async () => {
await initStorage();
if (Capacitor.isNativePlatform()) {
const savedApi = storageGetSync('gc_api_base_url');
if (savedApi && /localhost|127\.0\.0\.1/i.test(savedApi)) {
await storageRemove('gc_api_base_url');
}
}
refreshApiBaseFromStorage();
const stored = getAuthSession();
if (stored) {
try {
const me = await fetchAuthMe();
const me = await Promise.race([
fetchAuthMe(),
new Promise<null>((_, reject) => {
setTimeout(() => reject(new Error('session verify timeout')), 15_000);
}),
]);
if (me && !cancelled) {
const session = loginToSession(me);
setAuthSession(session);
@@ -84,7 +100,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAuthUser(session);
setGuestMode(false);
invalidateAppSettingsCache();
setSessionKey(k => k + 1);
void reloadAppSettingsCache().finally(() => {
setSessionKey(k => k + 1);
});
}, []);
const handleGuestEnter = useCallback(() => {
@@ -92,7 +110,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAuthUser(null);
setGuestMode(true);
invalidateAppSettingsCache();
setSessionKey(k => k + 1);
void reloadAppSettingsCache().finally(() => {
setSessionKey(k => k + 1);
});
}, []);
const handleLogout = useCallback(async () => {
+1
View File
@@ -4,4 +4,5 @@ export {
subscribeAppSettings,
resolveAppDefaults,
invalidateAppSettingsCache,
reloadAppSettingsCache,
} from '@frontend/hooks/useAppSettings';
+2 -3
View File
@@ -1,6 +1,5 @@
/** 웹 SplashScreen과 동일한 로그인 진입 화면 */
import SplashScreen from '@frontend/components/SplashScreen';
import type { LoginResponse } from '../lib/shared';
import MobileLoginScreen from './MobileLoginScreen';
interface Props {
onLoginSuccess: (res: LoginResponse) => void;
@@ -8,5 +7,5 @@ interface Props {
}
export default function LoginScreen({ onLoginSuccess, onGuest }: Props) {
return <SplashScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
return <MobileLoginScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구)
*/
import React, { useEffect, useState } from 'react';
import { loginUser, type LoginResponse } from '../lib/shared';
import '@frontend/styles/splashScreen.css';
const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin';
const APP_VERSION = '1.2';
interface Props {
onLoginSuccess: (res: LoginResponse) => void;
onGuest: () => void;
}
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
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);
useEffect(() => {
if (!loading) {
setSlowHint(false);
return;
}
const t = window.setTimeout(() => setSlowHint(true), 5000);
return () => window.clearTimeout(t);
}, [loading]);
const submitLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await loginUser(username.trim(), password);
onLoginSuccess(res);
} catch (err) {
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="splash">
<div className="splash-photo-bg" aria-hidden />
<div className="splash-photo-overlay" aria-hidden />
<div className="splash-center">
<div className="splash-glass">
<h1 className="splash-brand">GoldenChart</h1>
<form className="splash-form" onSubmit={submitLogin}>
<input
className="splash-input"
type="text"
autoComplete="username"
value={username}
onChange={ev => setUsername(ev.target.value)}
disabled={loading}
placeholder="ID"
/>
<input
className="splash-input"
type="password"
autoComplete="current-password"
value={password}
onChange={ev => setPassword(ev.target.value)}
disabled={loading}
placeholder="••••••••"
/>
{error && <p className="splash-error">{error}</p>}
{slowHint && loading && !error && (
<p className="splash-error" style={{ opacity: 0.85 }}>
. WiFi·
</p>
)}
<button type="submit" className="splash-login-btn" disabled={loading}>
{loading ? '로그인 중…' : '로그인'}
</button>
</form>
<p className="splash-version">
: {APP_VERSION} | Core Engine: Ta4j &amp; Spring Boot
</p>
</div>
<button type="button" className="splash-guest-link" onClick={onGuest} disabled={loading}>
</button>
</div>
</div>
);
}
@@ -1,7 +1,8 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
import { useNavigation } from '../../contexts/NavigationContext';
import { useAuth } from '../../contexts/AuthContext';
import { useAppSettings, reloadAppSettingsCache } from '../../hooks/useAppSettings';
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
import NotificationListRow from './NotificationListRow';
import NotificationDetailScreen from './NotificationDetailScreen';
@@ -15,7 +16,8 @@ export default function NotificationsScreen() {
goNotifyTrade,
goVirtualDetail,
} = useNavigation();
const { sessionKey } = useAuth();
const { sessionKey, guestMode, authUser } = useAuth();
const { isLoaded: settingsLoaded } = useAppSettings(sessionKey);
const {
allNotifications,
unreadCount,
@@ -28,16 +30,48 @@ export default function NotificationsScreen() {
const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey });
const [refreshing, setRefreshing] = useState(false);
const [pullY, setPullY] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const touchStartY = useRef(0);
const selectedItem = useMemo(
() => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null,
[allNotifications, notifyNav.notifyId],
);
const onRefresh = async () => {
const onRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
await refreshHistory();
setRefreshing(false);
try {
await reloadAppSettingsCache();
await refreshHistory({ serverOnly: true, rawServerList: true });
} finally {
setRefreshing(false);
setPullY(0);
}
}, [refreshing, refreshHistory]);
useEffect(() => {
if (!settingsLoaded) return;
void refreshHistory({ serverOnly: true, rawServerList: true });
}, [settingsLoaded, sessionKey, refreshHistory]);
const onTouchStart = (e: React.TouchEvent) => {
const el = listRef.current;
if (!el || el.scrollTop > 0) return;
touchStartY.current = e.touches[0].clientY;
};
const onTouchMove = (e: React.TouchEvent) => {
const el = listRef.current;
if (!el || el.scrollTop > 0) return;
const dy = e.touches[0].clientY - touchStartY.current;
if (dy > 0) setPullY(Math.min(dy, 72));
};
const onTouchEnd = () => {
if (pullY >= 48) void onRefresh();
else setPullY(0);
};
if (notifyNav.view === 'detail' && selectedItem) {
@@ -70,39 +104,76 @@ export default function NotificationsScreen() {
}
return (
<div className="screen">
<div className="screen notify-screen">
<header className="screen-header">
<h1 className="screen-title"> {unreadCount > 0 && `(${unreadCount})`}</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '↻'}</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
type="button"
className="chip notify-refresh-btn"
onClick={() => void onRefresh()}
disabled={refreshing}
aria-label="서버에서 알림 새로고침"
>
{refreshing ? '새로고침…' : '↻ 새로고침'}
</button>
<button type="button" className="chip" onClick={markAllAsRead}></button>
</div>
</header>
{allNotifications.length === 0 ? (
<div className="empty-state">
<h3> </h3>
<p> </p>
</div>
) : (
<div className="stack-list" style={{ padding: '0 16px' }}>
{allNotifications.map(item => (
<NotificationListRow
key={item.id}
item={item}
onDetail={() => {
markAsRead(item.id);
goNotifyDetail(item.id, item.market);
}}
onTrade={side => {
markAsRead(item.id);
goNotifyTrade(item.market, side);
}}
onDelete={() => void deleteNotification(item.id)}
/>
))}
</div>
{guestMode && (
<p className="notify-hint text-muted" style={{ padding: '0 16px 8px', fontSize: 12, margin: 0 }}>
.
</p>
)}
{!guestMode && authUser && (
<p className="notify-hint text-muted" style={{ padding: '0 16px 8px', fontSize: 12, margin: 0 }}>
{authUser.username} ·
</p>
)}
<div
ref={listRef}
className="notify-list-wrap"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{ flex: 1, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}
>
{(pullY > 0 || refreshing) && (
<div className="notify-pull-hint" style={{ textAlign: 'center', padding: pullY > 0 ? `${pullY / 4}px` : 8, fontSize: 12, color: 'var(--gc-text-muted)' }}>
{refreshing ? '서버에서 불러오는 중…' : pullY >= 48 ? '놓으면 새로고침' : '당겨서 새로고침'}
</div>
)}
{allNotifications.length === 0 ? (
<div className="empty-state">
<h3> </h3>
<p> </p>
<button type="button" className="btn-secondary" style={{ marginTop: 12 }} onClick={() => void onRefresh()}>
</button>
</div>
) : (
<div className="stack-list" style={{ padding: '0 16px' }}>
{allNotifications.map(item => (
<NotificationListRow
key={item.id}
item={item}
onDetail={() => {
markAsRead(item.id);
goNotifyDetail(item.id, item.market);
}}
onTrade={side => {
markAsRead(item.id);
goNotifyTrade(item.market, side);
}}
onDelete={() => void deleteNotification(item.id)}
/>
))}
</div>
)}
</div>
{allNotifications.length > 0 && (
<div style={{ padding: 16, textAlign: 'center' }}>
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useAppSettings } from '../../hooks/useAppSettings';
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
import { useNavigation } from '../../contexts/NavigationContext';
@@ -20,6 +21,7 @@ export default function VirtualTradingScreen() {
goVirtualHistory,
} = useNavigation();
const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
const core = useVirtualTradingCore({ settingsSessionKey });
const {
@@ -156,15 +158,15 @@ export default function VirtualTradingScreen() {
/>
</div>
{loading ? (
<div className="loading-center"> </div>
{!settingsLoaded || loading ? (
<div className="loading-center">{!settingsLoaded ? '설정 동기화 중…' : '로딩 중…'}</div>
) : targets.length === 0 ? (
<div className="empty-state">
<h3> </h3>
{guestMode ? (
<p> . .</p>
<p> . <strong> </strong>.</p>
) : (
<p> ({authUser?.username}) · + </p>
<p>({authUser?.username}) DB입니다. + </p>
)}
</div>
) : (
+52 -14
View File
@@ -5,6 +5,9 @@ import {
import { PushNotifications } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
/** AndroidManifest default_notification_channel_id 와 동일 */
export const FCM_CHANNEL_ID = 'goldenchart_trade_signals';
export interface FcmPayload {
type?: string;
market?: string;
@@ -19,42 +22,77 @@ type FcmHandlers = {
};
let initialized = false;
let listenersAttached = false;
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
if (!Capacitor.isNativePlatform()) {
console.info('[FCM] Web dev — native push skipped');
return false;
async function ensureAndroidNotificationChannel(): Promise<void> {
if (Capacitor.getPlatform() !== 'android') return;
try {
await PushNotifications.createChannel({
id: FCM_CHANNEL_ID,
name: '매매 시그널 알림',
description: '전략 조건 일치 시 매수·매도 푸시',
importance: 5,
visibility: 1,
vibration: true,
});
} catch (e) {
console.warn('[FCM] createChannel failed', e);
}
if (initialized) return true;
}
const perm = await PushNotifications.requestPermissions();
if (perm.receive !== 'granted') return false;
function attachListeners(handlers: FcmHandlers): void {
if (listenersAttached) return;
listenersAttached = true;
await PushNotifications.addListener('registration', async token => {
void PushNotifications.addListener('registration', async token => {
try {
await registerFcmToken(token.value);
console.info('[FCM] token registered');
console.info('[FCM] token registered with server');
} catch (e) {
console.warn('[FCM] token register failed', e);
}
});
await PushNotifications.addListener('registrationError', err => {
void PushNotifications.addListener('registrationError', err => {
console.warn('[FCM] registration error', err);
});
await PushNotifications.addListener('pushNotificationReceived', notification => {
void PushNotifications.addListener('pushNotificationReceived', notification => {
const title = notification.title ?? 'GoldenChart';
const body = notification.body ?? '';
handlers.onForeground?.(title, body, notification.data as FcmPayload);
});
await PushNotifications.addListener('pushNotificationActionPerformed', action => {
void PushNotifications.addListener('pushNotificationActionPerformed', action => {
handlers.onTap?.(action.notification.data as FcmPayload);
});
}
await PushNotifications.register();
initialized = true;
/**
* FCM 토큰 등록 (로그인 후 호출 — 서버 fcm_push_enabled 와 별개로 토큰만 등록)
*/
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
if (!Capacitor.isNativePlatform()) {
console.info('[FCM] Web dev — native push skipped');
return false;
}
await ensureAndroidNotificationChannel();
attachListeners(handlers);
const perm = await PushNotifications.checkPermissions();
if (perm.receive !== 'granted') {
const req = await PushNotifications.requestPermissions();
if (req.receive !== 'granted') {
console.warn('[FCM] notification permission denied');
return false;
}
}
if (!initialized) {
await PushNotifications.register();
initialized = true;
}
return true;
}