가상매매 시간봉 제거
This commit is contained in:
+5
-1
@@ -1 +1,5 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8080/api
|
# 웹 로컬 개발
|
||||||
|
# VITE_API_BASE_URL=http://localhost:8080/api
|
||||||
|
|
||||||
|
# 모바일 APK / exdev (HTTPS 443 미사용 — http 만)
|
||||||
|
VITE_API_BASE_URL=http://exdev.co.kr/api
|
||||||
|
|||||||
+6
-4
@@ -34,11 +34,13 @@ npm run cap:android # or cap:ios
|
|||||||
|
|
||||||
Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
|
Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
|
||||||
|
|
||||||
### 로그인 시 "Failed to fetch"
|
### 로그인 시 "Failed to fetch" / "서버 응답이 느립니다"
|
||||||
|
|
||||||
- APK는 빌드 시 `.env`의 `VITE_API_BASE_URL`이 번들에 박힙니다 (기본: `http://exdev.co.kr/api`).
|
- API는 **`http://exdev.co.kr/api`** 만 사용 (`https://exdev.co.kr` 은 443 미개방).
|
||||||
- `capacitor.config.ts`의 `androidScheme`은 **`http`** 여야 합니다. `https`이면 WebView가 `https://localhost`에서 뜨고 HTTP API 호출이 차단됩니다.
|
- exdev 로그인은 **10~30초** 걸릴 수 있음 — 2분 타임아웃까지 대기.
|
||||||
- 설정에 `localhost` API가 저장돼 있으면 앱 시작 시 자동으로 제거합니다.
|
- APK는 `app/.env`의 `VITE_API_BASE_URL`이 빌드에 포함되어야 함.
|
||||||
|
- `androidScheme`은 **`http`**. `CapacitorHttp`는 비활성(로그인 fetch 안정화).
|
||||||
|
- 설정에 `localhost`·`https://` API가 저장돼 있으면 앱 시작 시 제거·`http`로 교정.
|
||||||
|
|
||||||
## FCM (Firebase)
|
## FCM (Firebase)
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,18 @@
|
|||||||
## 2. 프로젝트에 배치
|
## 2. 프로젝트에 배치
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 권장: repo 루트 app/google-services.json (com.goldenchart.app 포함)
|
||||||
|
cp ~/Downloads/google-services.json app/google-services.json
|
||||||
|
./scripts/ensure-android-google-services.sh
|
||||||
|
|
||||||
|
# 또는 한 번에
|
||||||
./scripts/setup-android-fcm.sh ~/Downloads/google-services.json
|
./scripts/setup-android-fcm.sh ~/Downloads/google-services.json
|
||||||
# 또는
|
|
||||||
cp ~/Downloads/google-services.json app/android/app/google-services.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
빌드 시 `ensure-android-google-services.sh` 가 `app/google-services.json` 을
|
||||||
|
`app/android/app/google-services.json` 으로 복사합니다. **이 파일이 없으면**
|
||||||
|
알림 권한 허용 직후 `PushNotifications.register()` 에서 **앱이 종료**될 수 있습니다.
|
||||||
|
|
||||||
## 3. 빌드
|
## 3. 빌드
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -35,17 +42,21 @@ cd app/android && ./gradlew assembleRelease
|
|||||||
|
|
||||||
## 5. 앱에서 확인
|
## 5. 앱에서 확인
|
||||||
|
|
||||||
1. 로그인 후 **설정 → FCM 푸시** ON
|
1. **2.4 이상 APK** 설치 (`google-services.json` 포함 빌드 필수)
|
||||||
2. 알림 권한 허용
|
2. 크래시가 반복되면 **앱 삭제 후 재설치** (이전에 허용한 알림 권한 초기화)
|
||||||
3. **테스트 발송** 버튼
|
3. 로그인 → **설정 → FCM 푸시** ON
|
||||||
4. 전략 시그널 발생 시 푸시 수신
|
4. **「알림 허용 · FCM 등록」** 버튼 → 시스템 팝업에서 허용 (2초 후 등록)
|
||||||
|
5. **테스트 발송** 으로 확인
|
||||||
|
|
||||||
|
로그인·메인 진입 시에는 **알림 권한을 절대 요청하지 않습니다** (2.3+).
|
||||||
|
FCM 푸시 토글 ON → **「알림 허용 · FCM 등록」** 버튼을 눌렀을 때만 권한 팝업이 뜹니다.
|
||||||
|
|
||||||
## goldenApp 과의 차이
|
## goldenApp 과의 차이
|
||||||
|
|
||||||
| 항목 | goldenApp | GoldenChart (Capacitor) |
|
| 항목 | goldenApp | GoldenChart (Capacitor) |
|
||||||
|------|-----------|-------------------------|
|
|------|-----------|-------------------------|
|
||||||
| 패키지 | `com.golden.app` | `com.goldenchart.app` |
|
| 패키지 | `com.golden.app` | `com.goldenchart.app` |
|
||||||
| FCM SDK | 직접 Firebase Messaging | `@capacitor/push-notifications` |
|
| FCM SDK | 직접 Firebase Messaging | 앱 내장 `GoldenFcm` 플러그인 (Capacitor Push 미사용) |
|
||||||
| 토큰 등록 | `POST /api/fcm/token` | 동일 (`registerFcmToken`) |
|
| 토큰 등록 | `POST /api/fcm/token` | 동일 (`registerFcmToken`) |
|
||||||
| 알림 채널 | `golden_alerts` | `goldenchart_trade_signals` |
|
| 알림 채널 | `golden_alerts` | `goldenchart_trade_signals` |
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.goldenchart.app"
|
applicationId "com.goldenchart.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 7
|
versionCode 16
|
||||||
versionName "1.6"
|
versionName "2.5"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -40,15 +40,17 @@ dependencies {
|
|||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
implementation platform('com.google.firebase:firebase-bom:33.7.0')
|
||||||
|
implementation 'com.google.firebase:firebase-messaging'
|
||||||
|
implementation 'com.google.android.gms:play-services-base:18.5.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: 'capacitor.build.gradle'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
try {
|
def servicesJSON = file('google-services.json')
|
||||||
def servicesJSON = file('google-services.json')
|
if (!servicesJSON.exists()) {
|
||||||
if (servicesJSON.text) {
|
throw new GradleException(
|
||||||
apply plugin: 'com.google.gms.google-services'
|
'google-services.json 없음. 실행: ./scripts/ensure-android-google-services.sh'
|
||||||
}
|
)
|
||||||
} catch(Exception e) {
|
|
||||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
|
||||||
}
|
}
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ dependencies {
|
|||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
implementation project(':capacitor-haptics')
|
implementation project(':capacitor-haptics')
|
||||||
implementation project(':capacitor-preferences')
|
implementation project(':capacitor-preferences')
|
||||||
implementation project(':capacitor-push-notifications')
|
|
||||||
implementation project(':capacitor-splash-screen')
|
implementation project(':capacitor-splash-screen')
|
||||||
implementation project(':capacitor-status-bar')
|
implementation project(':capacitor-status-bar')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "353782909389",
|
||||||
|
"project_id": "goldenanalysisapp",
|
||||||
|
"storage_bucket": "goldenanalysisapp.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.golden.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:353782909389:android:62e71bbb669c64bbc75c84",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.goldenchart.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
@@ -18,6 +19,16 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
android:value="@string/default_notification_channel_id" />
|
android:value="@string/default_notification_channel_id" />
|
||||||
|
<!-- 권한 허용·앱 시작 시 FCM 자동 초기화 방지 — JS register() 시에만 활성화 -->
|
||||||
|
<meta-data
|
||||||
|
android:name="firebase_messaging_auto_init_enabled"
|
||||||
|
android:value="false"
|
||||||
|
tools:replace="android:value" />
|
||||||
|
|
||||||
|
<!-- Capacitor push-notifications 잔여 병합 제거 (이중 MessagingService 크래시 방지) -->
|
||||||
|
<service
|
||||||
|
android:name="com.capacitorjs.plugins.pushnotifications.MessagingService"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
@@ -34,6 +45,14 @@
|
|||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".GoldenChartMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.goldenchart.app;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||||
|
import com.google.firebase.messaging.RemoteMessage;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FCM 수신 — JS GoldenFcm 리스너로 전달
|
||||||
|
*/
|
||||||
|
public class GoldenChartMessagingService extends FirebaseMessagingService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNewToken(@NonNull String token) {
|
||||||
|
super.onNewToken(token);
|
||||||
|
GoldenFcmPlugin.emitRegistration(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessageReceived(@NonNull RemoteMessage message) {
|
||||||
|
super.onMessageReceived(message);
|
||||||
|
JSObject notification = new JSObject();
|
||||||
|
RemoteMessage.Notification n = message.getNotification();
|
||||||
|
if (n != null) {
|
||||||
|
if (n.getTitle() != null) notification.put("title", n.getTitle());
|
||||||
|
if (n.getBody() != null) notification.put("body", n.getBody());
|
||||||
|
}
|
||||||
|
JSObject data = new JSObject();
|
||||||
|
for (Map.Entry<String, String> e : message.getData().entrySet()) {
|
||||||
|
data.put(e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
notification.put("data", data);
|
||||||
|
GoldenFcmPlugin.emitPushReceived(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.goldenchart.app;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
import com.getcapacitor.annotation.Permission;
|
||||||
|
import com.getcapacitor.annotation.PermissionCallback;
|
||||||
|
import com.google.android.gms.common.ConnectionResult;
|
||||||
|
import com.google.android.gms.common.GoogleApiAvailability;
|
||||||
|
import com.google.firebase.FirebaseApp;
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FCM — Capacitor PushNotifications 미사용 (크래시 방지)
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(
|
||||||
|
name = "GoldenFcm",
|
||||||
|
permissions = {
|
||||||
|
@Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = GoldenFcmPlugin.PERM)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class GoldenFcmPlugin extends Plugin {
|
||||||
|
|
||||||
|
static final String PERM = "notifications";
|
||||||
|
private static final String TAG = "GoldenFcm";
|
||||||
|
private static GoldenFcmPlugin instance;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void load() {
|
||||||
|
instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void emitRegistration(String token) {
|
||||||
|
if (instance == null || token == null) return;
|
||||||
|
JSObject data = new JSObject();
|
||||||
|
data.put("value", token);
|
||||||
|
instance.notifyListeners("registration", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void emitRegistrationError(String message) {
|
||||||
|
if (instance == null) return;
|
||||||
|
JSObject data = new JSObject();
|
||||||
|
data.put("error", message != null ? message : "unknown");
|
||||||
|
instance.notifyListeners("registrationError", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void emitPushReceived(JSObject notification) {
|
||||||
|
if (instance == null) return;
|
||||||
|
instance.notifyListeners("pushNotificationReceived", notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void emitPushAction(JSObject action) {
|
||||||
|
if (instance == null) return;
|
||||||
|
instance.notifyListeners("pushNotificationActionPerformed", action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String permissionStateToString(com.getcapacitor.PermissionState state) {
|
||||||
|
if (state == com.getcapacitor.PermissionState.GRANTED) return "granted";
|
||||||
|
if (state == com.getcapacitor.PermissionState.DENIED) return "denied";
|
||||||
|
return "prompt";
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean ensurePlayServices(PluginCall call) {
|
||||||
|
try {
|
||||||
|
int code = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(getContext());
|
||||||
|
if (code != ConnectionResult.SUCCESS) {
|
||||||
|
call.reject("Google Play 서비스 필요 (코드 " + code + ")");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
call.reject("Play 서비스 확인 실패: " + t.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void checkPermissions(PluginCall call) {
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
r.put("receive", "granted");
|
||||||
|
call.resolve(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.put("receive", permissionStateToString(getPermissionState(PERM)));
|
||||||
|
call.resolve(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 알림 권한만 요청 — FCM register 호출 없음 */
|
||||||
|
@PluginMethod
|
||||||
|
public void requestPermissions(PluginCall call) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("receive", "granted");
|
||||||
|
call.resolve(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getPermissionState(PERM) == com.getcapacitor.PermissionState.GRANTED) {
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("receive", "granted");
|
||||||
|
call.resolve(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
call.setKeepAlive(true);
|
||||||
|
requestPermissionForAlias(PERM, call, "onPermResult");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PermissionCallback
|
||||||
|
private void onPermResult(PluginCall call) {
|
||||||
|
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||||
|
try {
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("receive", permissionStateToString(getPermissionState(PERM)));
|
||||||
|
call.resolve(r);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "onPermResult", t);
|
||||||
|
call.reject(t.getMessage());
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 권한 허용 후 별도 호출 — Firebase getToken */
|
||||||
|
@PluginMethod
|
||||||
|
public void register(PluginCall call) {
|
||||||
|
call.setKeepAlive(true);
|
||||||
|
|
||||||
|
if (getActivity() == null || getActivity().isFinishing()) {
|
||||||
|
call.reject("Activity unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ensurePlayServices(call)) return;
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||||
|
&& getPermissionState(PERM) != com.getcapacitor.PermissionState.GRANTED) {
|
||||||
|
call.reject("알림 권한이 필요합니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Executors.newSingleThreadExecutor().execute(() -> {
|
||||||
|
try {
|
||||||
|
if (FirebaseApp.getApps(getContext()).isEmpty()) {
|
||||||
|
FirebaseApp.initializeApp(getContext());
|
||||||
|
}
|
||||||
|
FirebaseMessaging.getInstance().setAutoInitEnabled(true);
|
||||||
|
FirebaseMessaging.getInstance()
|
||||||
|
.getToken()
|
||||||
|
.addOnCompleteListener(task -> {
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> {
|
||||||
|
if (!task.isSuccessful()) {
|
||||||
|
Exception ex = task.getException();
|
||||||
|
String msg = ex != null ? ex.getMessage() : "getToken failed";
|
||||||
|
Log.w(TAG, "getToken failed", ex);
|
||||||
|
emitRegistrationError(msg);
|
||||||
|
call.reject(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String token = task.getResult();
|
||||||
|
Log.i(TAG, "FCM token ok");
|
||||||
|
emitRegistration(token);
|
||||||
|
JSObject ret = new JSObject();
|
||||||
|
ret.put("value", token);
|
||||||
|
call.resolve(ret);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "register", t);
|
||||||
|
new Handler(Looper.getMainLooper()).post(() ->
|
||||||
|
call.reject(t.getMessage() != null ? t.getMessage() : "register failed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void unregister(PluginCall call) {
|
||||||
|
try {
|
||||||
|
if (!FirebaseApp.getApps(getContext()).isEmpty()) {
|
||||||
|
FirebaseMessaging.getInstance().setAutoInitEnabled(false);
|
||||||
|
FirebaseMessaging.getInstance().deleteToken();
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "unregister", t);
|
||||||
|
}
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,20 +4,37 @@ import android.app.NotificationChannel;
|
|||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
import com.google.firebase.FirebaseApp;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capacitor 메인 액티비티 — FCM 알림 채널 생성 (goldenApp / Android 8+)
|
* Capacitor 메인 액티비티 — GoldenFcm 플러그인 + 알림 채널
|
||||||
*/
|
*/
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
|
private static final String TAG = "GoldenChart";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
registerPlugin(GoldenFcmPlugin.class);
|
||||||
|
initFirebaseSafe();
|
||||||
createNotificationChannel();
|
createNotificationChannel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void initFirebaseSafe() {
|
||||||
|
try {
|
||||||
|
if (FirebaseApp.getApps(this).isEmpty()) {
|
||||||
|
FirebaseApp.initializeApp(this);
|
||||||
|
Log.i(TAG, "FirebaseApp initialized");
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "FirebaseApp init failed", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void createNotificationChannel() {
|
private void createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
String channelId = getString(R.string.default_notification_channel_id);
|
String channelId = getString(R.string.default_notification_channel_id);
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacit
|
|||||||
include ':capacitor-preferences'
|
include ':capacitor-preferences'
|
||||||
project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android')
|
project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android')
|
||||||
|
|
||||||
include ':capacitor-push-notifications'
|
|
||||||
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android')
|
|
||||||
|
|
||||||
include ':capacitor-splash-screen'
|
include ':capacitor-splash-screen'
|
||||||
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android')
|
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android')
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,15 @@ const config: CapacitorConfig = {
|
|||||||
iosScheme: 'capacitor',
|
iosScheme: 'capacitor',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
// 네이티브 HTTP + AbortSignal 조합이 로그인 fetch 를 끊는 경우가 있어 WebView fetch 사용
|
||||||
CapacitorHttp: {
|
CapacitorHttp: {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
},
|
},
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
launchAutoHide: true,
|
launchAutoHide: true,
|
||||||
backgroundColor: '#0f0f23',
|
backgroundColor: '#0f0f23',
|
||||||
showSpinner: false,
|
showSpinner: false,
|
||||||
},
|
},
|
||||||
PushNotifications: {
|
|
||||||
presentationOptions: ['badge', 'sound', 'alert'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "353782909389",
|
||||||
|
"project_id": "goldenanalysisapp",
|
||||||
|
"storage_bucket": "goldenanalysisapp.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.golden.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:353782909389:android:62e71bbb669c64bbc75c84",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.goldenchart.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
+1
-2
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"cap:sync": "npm run build && npx cap sync",
|
"cap:sync": "npm run build && bash ../scripts/ensure-android-google-services.sh && npx cap sync",
|
||||||
"cap:open:android": "npx cap open android",
|
"cap:open:android": "npx cap open android",
|
||||||
"cap:open:ios": "npx cap open ios"
|
"cap:open:ios": "npx cap open ios"
|
||||||
},
|
},
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"@capacitor/haptics": "^7.0.0",
|
"@capacitor/haptics": "^7.0.0",
|
||||||
"@capacitor/ios": "^7.0.0",
|
"@capacitor/ios": "^7.0.0",
|
||||||
"@capacitor/preferences": "^7.0.0",
|
"@capacitor/preferences": "^7.0.0",
|
||||||
"@capacitor/push-notifications": "^7.0.0",
|
|
||||||
"@capacitor/splash-screen": "^7.0.0",
|
"@capacitor/splash-screen": "^7.0.0",
|
||||||
"@capacitor/status-bar": "^7.0.0",
|
"@capacitor/status-bar": "^7.0.0",
|
||||||
"@goldenchart/shared": "*",
|
"@goldenchart/shared": "*",
|
||||||
|
|||||||
+2
-28
@@ -7,7 +7,6 @@ import { NavigationProvider, useNavigation } from './contexts/NavigationContext'
|
|||||||
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
|
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
|
||||||
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
|
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
|
||||||
import TabBar, { type TabId } from './components/TabBar';
|
import TabBar, { type TabId } from './components/TabBar';
|
||||||
import { initFcmPush, type FcmPayload } from './services/fcm';
|
|
||||||
import LiveSignalBridge from './components/LiveSignalBridge';
|
import LiveSignalBridge from './components/LiveSignalBridge';
|
||||||
import LoginScreen from './screens/LoginScreen';
|
import LoginScreen from './screens/LoginScreen';
|
||||||
import '@frontend/styles/splashScreen.css';
|
import '@frontend/styles/splashScreen.css';
|
||||||
@@ -37,37 +36,12 @@ function ToastStack() {
|
|||||||
|
|
||||||
function MainApp() {
|
function MainApp() {
|
||||||
const { tab, setTab, openVirtualFocus, goVirtualList, goNotifyList } = useNavigation();
|
const { tab, setTab, openVirtualFocus, goVirtualList, goNotifyList } = useNavigation();
|
||||||
const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
|
const { unreadCount } = useTradeNotification();
|
||||||
const { sessionKey } = useAuth();
|
const { sessionKey } = useAuth();
|
||||||
const { settings, isLoaded } = useAppSettings(sessionKey);
|
const { settings, isLoaded } = useAppSettings(sessionKey);
|
||||||
const defaults = resolveAppDefaults(settings);
|
const defaults = resolveAppDefaults(settings);
|
||||||
|
|
||||||
useEffect(() => {
|
/** FCM/알림 권한: 로그인·메인 진입 시 호출하지 않음 — 설정 버튼에서만 */
|
||||||
if (!isLoaded) return;
|
|
||||||
void initFcmPush({
|
|
||||||
onForeground: (_title, _body, data) => {
|
|
||||||
if (data?.market && data?.signalType) {
|
|
||||||
addNotification({
|
|
||||||
market: data.market,
|
|
||||||
signalType: data.signalType === 'SELL' ? 'SELL' : 'BUY',
|
|
||||||
price: Number(data.price) || 0,
|
|
||||||
candleTime: Math.floor(Date.now() / 1000),
|
|
||||||
dbId: data.signalId ? Number(data.signalId) : undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
void refreshHistory();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTap: (data: FcmPayload) => {
|
|
||||||
if (data.market) {
|
|
||||||
setTab('virtual');
|
|
||||||
openVirtualFocus(data.market);
|
|
||||||
} else {
|
|
||||||
setTab('notifications');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [isLoaded, addNotification, refreshHistory, setTab, openVirtualFocus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
} from '../lib/shared';
|
} from '../lib/shared';
|
||||||
import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings';
|
import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings';
|
||||||
|
import { ensureMobileApiBase } from '../lib/ensureMobileApiBase';
|
||||||
|
|
||||||
function normalizeRole(role: string): AuthSession['role'] {
|
function normalizeRole(role: string): AuthSession['role'] {
|
||||||
return role === 'ADMIN' ? 'ADMIN' : 'USER';
|
return role === 'ADMIN' ? 'ADMIN' : 'USER';
|
||||||
@@ -52,18 +53,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [authUser, setAuthUser] = useState<AuthSession | null>(null);
|
const [authUser, setAuthUser] = useState<AuthSession | null>(null);
|
||||||
const [guestMode, setGuestMode] = useState(false);
|
const [guestMode, setGuestMode] = useState(false);
|
||||||
const [sessionKey, setSessionKey] = useState(0);
|
const [sessionKey, setSessionKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await initStorage();
|
await initStorage();
|
||||||
if (Capacitor.isNativePlatform()) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
const savedApi = storageGetSync('gc_api_base_url');
|
const savedApi = storageGetSync('gc_api_base_url');
|
||||||
if (savedApi && /localhost|127\.0\.0\.1/i.test(savedApi)) {
|
if (
|
||||||
|
savedApi
|
||||||
|
&& (/localhost|127\.0\.0\.1/i.test(savedApi) || /^https:/i.test(savedApi))
|
||||||
|
) {
|
||||||
await storageRemove('gc_api_base_url');
|
await storageRemove('gc_api_base_url');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshApiBaseFromStorage();
|
ensureMobileApiBase();
|
||||||
const stored = getAuthSession();
|
const stored = getAuthSession();
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
@@ -99,20 +102,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
setAuthUser(session);
|
setAuthUser(session);
|
||||||
setGuestMode(false);
|
setGuestMode(false);
|
||||||
invalidateAppSettingsCache();
|
|
||||||
void reloadAppSettingsCache().finally(() => {
|
|
||||||
setSessionKey(k => k + 1);
|
setSessionKey(k => k + 1);
|
||||||
});
|
invalidateAppSettingsCache();
|
||||||
|
void reloadAppSettingsCache();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleGuestEnter = useCallback(() => {
|
const handleGuestEnter = useCallback(() => {
|
||||||
void clearAuthSession();
|
void clearAuthSession();
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setGuestMode(true);
|
setGuestMode(true);
|
||||||
invalidateAppSettingsCache();
|
|
||||||
void reloadAppSettingsCache().finally(() => {
|
|
||||||
setSessionKey(k => k + 1);
|
setSessionKey(k => k + 1);
|
||||||
});
|
invalidateAppSettingsCache();
|
||||||
|
void reloadAppSettingsCache();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { App } from '@capacitor/app';
|
||||||
|
|
||||||
|
/** 네이티브: build.gradle versionName / versionCode · 웹: dev */
|
||||||
|
export function useAppVersion(): string {
|
||||||
|
const [label, setLabel] = useState('…');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
try {
|
||||||
|
const { version, build } = await App.getInfo();
|
||||||
|
setLabel(build ? `${version} (build ${build})` : version);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLabel(
|
||||||
|
(import.meta as ImportMeta & { env?: { VITE_APP_VERSION?: string } }).env
|
||||||
|
?.VITE_APP_VERSION ?? 'web',
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import {
|
||||||
|
normalizeApiBaseUrl,
|
||||||
|
PRODUCTION_API_BASE,
|
||||||
|
refreshApiBaseFromStorage,
|
||||||
|
setApiBase,
|
||||||
|
storageGetSync,
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
/** 네이티브 APK — 잘못된 API(localhost·https) 제거 후 exdev 고정 */
|
||||||
|
export function ensureMobileApiBase(): string {
|
||||||
|
refreshApiBaseFromStorage();
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
return storageGetSync('gc_api_base_url') ?? PRODUCTION_API_BASE;
|
||||||
|
}
|
||||||
|
const saved = normalizeApiBaseUrl(storageGetSync('gc_api_base_url'));
|
||||||
|
const base = saved ?? PRODUCTION_API_BASE;
|
||||||
|
setApiBase(base);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구)
|
* 모바일 로그인 — SplashScreen UI + 느린 서버(exdev) 대응
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useState } from 'react';
|
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';
|
import '@frontend/styles/splashScreen.css';
|
||||||
|
|
||||||
const DEFAULT_USERNAME = 'admin';
|
const DEFAULT_USERNAME = 'admin';
|
||||||
const DEFAULT_PASSWORD = 'admin';
|
const DEFAULT_PASSWORD = 'admin';
|
||||||
const APP_VERSION = '1.2';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLoginSuccess: (res: LoginResponse) => void;
|
onLoginSuccess: (res: LoginResponse) => void;
|
||||||
@@ -15,23 +20,38 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||||
|
const appVersion = useAppVersion();
|
||||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [slowHint, setSlowHint] = 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(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
setSlowHint(false);
|
setSlowHint(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => setSlowHint(true), 5000);
|
const t = window.setTimeout(() => setSlowHint(true), 4000);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [loading]);
|
}, [loading]);
|
||||||
|
|
||||||
const submitLogin = async (e: React.FormEvent) => {
|
const submitLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!storageReady) {
|
||||||
|
setError('앱 초기화 중입니다. 잠시 후 다시 시도하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -60,7 +80,7 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={ev => setUsername(ev.target.value)}
|
onChange={ev => setUsername(ev.target.value)}
|
||||||
disabled={loading}
|
disabled={loading || !storageReady}
|
||||||
placeholder="ID"
|
placeholder="ID"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -69,22 +89,29 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={ev => setPassword(ev.target.value)}
|
onChange={ev => setPassword(ev.target.value)}
|
||||||
disabled={loading}
|
disabled={loading || !storageReady}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
{error && <p className="splash-error">{error}</p>}
|
{error && <p className="splash-error">{error}</p>}
|
||||||
{slowHint && loading && !error && (
|
{slowHint && loading && !error && (
|
||||||
<p className="splash-error" style={{ opacity: 0.85 }}>
|
<p className="splash-error" style={{ opacity: 0.85 }}>
|
||||||
서버 응답이 느립니다. Wi‑Fi·모바일 데이터를 확인해 주세요…
|
exdev 서버가 느립니다. 첫 로그인은 <strong>최대 3분</strong> 걸릴 수 있습니다.
|
||||||
|
버튼이 「로그인 중…」인 동안 기다려 주세요.
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: 11 }}>API: {apiBase || '…'}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button type="submit" className="splash-login-btn" disabled={loading}>
|
<button
|
||||||
{loading ? '로그인 중…' : '로그인'}
|
type="submit"
|
||||||
|
className="splash-login-btn"
|
||||||
|
disabled={loading || !storageReady}
|
||||||
|
>
|
||||||
|
{!storageReady ? '준비 중…' : loading ? '로그인 중…' : '로그인'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="splash-version">
|
<p className="splash-version">
|
||||||
버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot
|
APK {appVersion} · API {apiBase.replace(/^https?:\/\//, '')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||||
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
||||||
import { initFcmPush, checkPushPermission } from '../../services/fcm';
|
import { API_BASE, normalizeApiBaseUrl, PRODUCTION_API_BASE } from '../../lib/shared';
|
||||||
import { API_BASE } from '../../lib/shared';
|
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { authUser, guestMode, sessionKey, handleLogout } = useAuth();
|
const { authUser, guestMode, sessionKey, handleLogout } = useAuth();
|
||||||
@@ -19,11 +18,11 @@ export default function SettingsScreen() {
|
|||||||
const defaults = resolveAppDefaults(settings);
|
const defaults = resolveAppDefaults(settings);
|
||||||
const [apiUrl, setApiUrl] = useState(API_BASE);
|
const [apiUrl, setApiUrl] = useState(API_BASE);
|
||||||
const [fcmAvailable, setFcmAvailable] = useState(false);
|
const [fcmAvailable, setFcmAvailable] = useState(false);
|
||||||
const [pushPerm, setPushPerm] = useState<string>('prompt');
|
const [pushPerm, setPushPerm] = useState<string>('—');
|
||||||
|
const [fcmBusy, setFcmBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
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]);
|
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>
|
||||||
|
|
||||||
<SettingGroup title="FCM 푸시">
|
<SettingGroup title="FCM 푸시">
|
||||||
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
|
<SettingRow
|
||||||
|
label="푸시 알림"
|
||||||
|
description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정 · 1.9+ APK 필요'}
|
||||||
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={!!defaults.fcmPushEnabled}
|
checked={!!defaults.fcmPushEnabled}
|
||||||
onChange={v => {
|
onChange={v => {
|
||||||
patch({ fcmPushEnabled: v });
|
patch({ fcmPushEnabled: v });
|
||||||
if (v) void initFcmPush().then(() => checkPushPermission().then(setPushPerm));
|
if (!v) {
|
||||||
|
void import('../../services/fcm').then(m => m.unregisterFcmPush());
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
label="FCM 푸시"
|
label="FCM 푸시"
|
||||||
/>
|
/>
|
||||||
@@ -146,6 +150,56 @@ export default function SettingsScreen() {
|
|||||||
<SettingRow label="권한 상태">
|
<SettingRow label="권한 상태">
|
||||||
<span className="text-muted">{pushPerm}</span>
|
<span className="text-muted">{pushPerm}</span>
|
||||||
</SettingRow>
|
</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="테스트 발송">
|
<SettingRow label="테스트 발송">
|
||||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void sendFcmTest()}>테스트</button>
|
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void sendFcmTest()}>테스트</button>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -160,7 +214,18 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="적용">
|
<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>
|
||||||
<SettingRow label="Device ID">
|
<SettingRow label="Device ID">
|
||||||
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
session: VirtualSessionConfig;
|
session: VirtualSessionConfig;
|
||||||
strategies: StrategyDto[];
|
strategies: StrategyDto[];
|
||||||
snapshot?: VirtualIndicatorSnapshot;
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
|
signalLoading?: boolean;
|
||||||
liveConnected: boolean;
|
liveConnected: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onTrade: (side: TradeSide) => void;
|
onTrade: (side: TradeSide) => void;
|
||||||
@@ -20,6 +21,7 @@ interface Props {
|
|||||||
export default function VirtualFocusScreen({
|
export default function VirtualFocusScreen({
|
||||||
target,
|
target,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
signalLoading,
|
||||||
liveConnected,
|
liveConnected,
|
||||||
onBack,
|
onBack,
|
||||||
onTrade,
|
onTrade,
|
||||||
@@ -72,6 +74,11 @@ export default function VirtualFocusScreen({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>조건 목록</h3>
|
<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">
|
<div className="stack-list">
|
||||||
{(snapshot?.rows ?? []).map(row => (
|
{(snapshot?.rows ?? []).map(row => (
|
||||||
<div key={row.id} className="card mobile-history-row">
|
<div key={row.id} className="card mobile-history-row">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
globalStrategyId: number | null;
|
globalStrategyId: number | null;
|
||||||
strategies: StrategyDto[];
|
strategies: StrategyDto[];
|
||||||
snapshot?: VirtualIndicatorSnapshot;
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
|
signalLoading?: boolean;
|
||||||
liveStatus?: VirtualLiveStatus;
|
liveStatus?: VirtualLiveStatus;
|
||||||
onDetail: () => void;
|
onDetail: () => void;
|
||||||
onTrade: (side: TradeSide) => void;
|
onTrade: (side: TradeSide) => void;
|
||||||
@@ -23,6 +24,7 @@ export default function VirtualTargetListRow({
|
|||||||
globalStrategyId,
|
globalStrategyId,
|
||||||
strategies,
|
strategies,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
signalLoading,
|
||||||
liveStatus,
|
liveStatus,
|
||||||
onDetail,
|
onDetail,
|
||||||
onTrade,
|
onTrade,
|
||||||
@@ -42,7 +44,10 @@ export default function VirtualTargetListRow({
|
|||||||
<span>{target.koreanName ?? target.market}</span>
|
<span>{target.koreanName ?? target.market}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted mobile-list-row-meta">
|
<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>
|
</div>
|
||||||
<div className="mobile-list-row-pin">
|
<div className="mobile-list-row-pin">
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default function VirtualTradingScreen() {
|
|||||||
trades,
|
trades,
|
||||||
loading,
|
loading,
|
||||||
snapshots,
|
snapshots,
|
||||||
|
snapshotLoadingByMarket,
|
||||||
liveStatusByMarket,
|
liveStatusByMarket,
|
||||||
handleStart,
|
handleStart,
|
||||||
handleStop,
|
handleStop,
|
||||||
@@ -61,6 +62,7 @@ export default function VirtualTradingScreen() {
|
|||||||
session={session}
|
session={session}
|
||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
snapshot={snap}
|
snapshot={snap}
|
||||||
|
signalLoading={snapshotLoadingByMarket[market]}
|
||||||
liveConnected={liveConnected}
|
liveConnected={liveConnected}
|
||||||
onBack={goVirtualList}
|
onBack={goVirtualList}
|
||||||
onTrade={side => goVirtualTrade(market, side)}
|
onTrade={side => goVirtualTrade(market, side)}
|
||||||
@@ -175,6 +177,7 @@ export default function VirtualTradingScreen() {
|
|||||||
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
|
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
|
||||||
const snapKey = `${t.market}:${strategyId ?? ''}`;
|
const snapKey = `${t.market}:${strategyId ?? ''}`;
|
||||||
const snap = snapshots[snapKey] ?? snapshots[t.market];
|
const snap = snapshots[snapKey] ?? snapshots[t.market];
|
||||||
|
const signalLoading = snapshotLoadingByMarket[t.market];
|
||||||
return (
|
return (
|
||||||
<VirtualTargetListRow
|
<VirtualTargetListRow
|
||||||
key={t.market}
|
key={t.market}
|
||||||
@@ -182,6 +185,7 @@ export default function VirtualTradingScreen() {
|
|||||||
globalStrategyId={session.globalStrategyId}
|
globalStrategyId={session.globalStrategyId}
|
||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
snapshot={snap}
|
snapshot={snap}
|
||||||
|
signalLoading={signalLoading}
|
||||||
liveStatus={liveStatusByMarket[t.market]}
|
liveStatus={liveStatusByMarket[t.market]}
|
||||||
onDetail={() => goVirtualDetail(t.market)}
|
onDetail={() => goVirtualDetail(t.market)}
|
||||||
onTrade={side => goVirtualTrade(t.market, side)}
|
onTrade={side => goVirtualTrade(t.market, side)}
|
||||||
|
|||||||
+110
-55
@@ -1,12 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
registerFcmToken,
|
registerFcmToken,
|
||||||
deleteFcmToken,
|
deleteFcmToken,
|
||||||
|
storageGet,
|
||||||
|
storageSet,
|
||||||
|
storageRemove,
|
||||||
} from '../lib/shared';
|
} from '../lib/shared';
|
||||||
import { PushNotifications } from '@capacitor/push-notifications';
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
import { Capacitor } from '@capacitor/core';
|
|
||||||
|
|
||||||
/** AndroidManifest default_notification_channel_id 와 동일 */
|
|
||||||
export const FCM_CHANNEL_ID = 'goldenchart_trade_signals';
|
export const FCM_CHANNEL_ID = 'goldenchart_trade_signals';
|
||||||
|
export const FCM_USER_OPT_IN_KEY = 'gc_fcm_user_opt_in';
|
||||||
|
export const FCM_REGISTERED_KEY = 'gc_fcm_registered';
|
||||||
|
|
||||||
export interface FcmPayload {
|
export interface FcmPayload {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -21,93 +24,145 @@ type FcmHandlers = {
|
|||||||
onTap?: (data: FcmPayload) => void;
|
onTap?: (data: FcmPayload) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let initialized = false;
|
interface GoldenFcmPlugin {
|
||||||
let listenersAttached = false;
|
checkPermissions(): Promise<{ receive: string }>;
|
||||||
|
requestPermissions(): Promise<{ receive: string }>;
|
||||||
async function ensureAndroidNotificationChannel(): Promise<void> {
|
register(): Promise<{ value: string }>;
|
||||||
if (Capacitor.getPlatform() !== 'android') return;
|
unregister(): Promise<void>;
|
||||||
try {
|
addListener(
|
||||||
await PushNotifications.createChannel({
|
event: 'registration',
|
||||||
id: FCM_CHANNEL_ID,
|
listener: (data: { value: string }) => void,
|
||||||
name: '매매 시그널 알림',
|
): Promise<{ remove: () => void }>;
|
||||||
description: '전략 조건 일치 시 매수·매도 푸시',
|
addListener(
|
||||||
importance: 5,
|
event: 'registrationError',
|
||||||
visibility: 1,
|
listener: (data: { error?: string }) => void,
|
||||||
vibration: true,
|
): Promise<{ remove: () => void }>;
|
||||||
});
|
addListener(
|
||||||
} catch (e) {
|
event: 'pushNotificationReceived',
|
||||||
console.warn('[FCM] createChannel failed', e);
|
listener: (data: { title?: string; body?: string; data?: FcmPayload }) => void,
|
||||||
}
|
): Promise<{ remove: () => void }>;
|
||||||
|
addListener(
|
||||||
|
event: 'pushNotificationActionPerformed',
|
||||||
|
listener: (data: { notification?: { data?: FcmPayload } }) => void,
|
||||||
|
): Promise<{ remove: () => void }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachListeners(handlers: FcmHandlers): void {
|
const GoldenFcm = registerPlugin<GoldenFcmPlugin>('GoldenFcm');
|
||||||
if (listenersAttached) return;
|
|
||||||
|
let tokenRegistered = false;
|
||||||
|
let listenersAttached = false;
|
||||||
|
|
||||||
|
async function userOptedIn(): Promise<boolean> {
|
||||||
|
return (await storageGet(FCM_USER_OPT_IN_KEY)) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markFcmUserOptIn(): Promise<void> {
|
||||||
|
await storageSet(FCM_USER_OPT_IN_KEY, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachListeners(handlers: {
|
||||||
|
onForeground?: (title: string, body: string, data?: FcmPayload) => void;
|
||||||
|
onTap?: (data: FcmPayload) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (listenersAttached || Capacitor.getPlatform() !== 'android') return;
|
||||||
listenersAttached = true;
|
listenersAttached = true;
|
||||||
|
|
||||||
void PushNotifications.addListener('registration', async token => {
|
await GoldenFcm.addListener('registration', async data => {
|
||||||
try {
|
try {
|
||||||
await registerFcmToken(token.value);
|
await registerFcmToken(data.value);
|
||||||
console.info('[FCM] token registered with server');
|
await storageSet(FCM_REGISTERED_KEY, '1');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[FCM] token register failed', e);
|
console.warn('[FCM] token register failed', e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
void PushNotifications.addListener('registrationError', err => {
|
await GoldenFcm.addListener('registrationError', err => {
|
||||||
console.warn('[FCM] registration error', err);
|
console.warn('[FCM] registration error', err);
|
||||||
|
void storageRemove(FCM_REGISTERED_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
void PushNotifications.addListener('pushNotificationReceived', notification => {
|
await GoldenFcm.addListener('pushNotificationReceived', notification => {
|
||||||
const title = notification.title ?? 'GoldenChart';
|
const title = notification.title ?? 'GoldenChart';
|
||||||
const body = notification.body ?? '';
|
const body = notification.body ?? '';
|
||||||
handlers.onForeground?.(title, body, notification.data as FcmPayload);
|
handlers.onForeground?.(title, body, notification.data as FcmPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
void PushNotifications.addListener('pushNotificationActionPerformed', action => {
|
await GoldenFcm.addListener('pushNotificationActionPerformed', action => {
|
||||||
handlers.onTap?.(action.notification.data as FcmPayload);
|
handlers.onTap?.(action.notification?.data as FcmPayload);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** ① 알림 권한만 (FCM 미호출 — 허용 직후 크래시 방지) */
|
||||||
* FCM 토큰 등록 (로그인 후 호출 — 서버 fcm_push_enabled 와 별개로 토큰만 등록)
|
export async function requestNotificationPermissionOnly(): Promise<boolean> {
|
||||||
*/
|
if (Capacitor.getPlatform() !== 'android') return false;
|
||||||
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
|
await markFcmUserOptIn();
|
||||||
if (!Capacitor.isNativePlatform()) {
|
const perm = await GoldenFcm.checkPermissions();
|
||||||
console.info('[FCM] Web dev — native push skipped');
|
if (perm.receive === 'granted') return true;
|
||||||
|
const req = await GoldenFcm.requestPermissions();
|
||||||
|
return req.receive === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ② 권한 허용 후 FCM 토큰 등록 */
|
||||||
|
export async function registerFcmTokenOnly(): Promise<boolean> {
|
||||||
|
if (Capacitor.getPlatform() !== 'android') return false;
|
||||||
|
if (!(await userOptedIn())) return false;
|
||||||
|
|
||||||
|
const perm = await GoldenFcm.checkPermissions();
|
||||||
|
if (perm.receive !== 'granted') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await attachListeners({});
|
||||||
|
if (tokenRegistered) return true;
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 800));
|
||||||
|
const result = await GoldenFcm.register();
|
||||||
|
tokenRegistered = true;
|
||||||
|
if (result?.value) {
|
||||||
|
await registerFcmToken(result.value);
|
||||||
|
await storageSet(FCM_REGISTERED_KEY, '1');
|
||||||
|
}
|
||||||
|
return !!result?.value;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[FCM] register failed', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ensureAndroidNotificationChannel();
|
export async function isFcmRegisteredOnDevice(): Promise<boolean> {
|
||||||
attachListeners(handlers);
|
return (await storageGet(FCM_REGISTERED_KEY)) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
const perm = await PushNotifications.checkPermissions();
|
/** @deprecated — requestNotificationPermissionOnly + registerFcmTokenOnly 사용 */
|
||||||
if (perm.receive !== 'granted') {
|
export async function initFcmPush(): Promise<boolean> {
|
||||||
const req = await PushNotifications.requestPermissions();
|
const ok = await requestNotificationPermissionOnly();
|
||||||
if (req.receive !== 'granted') {
|
if (!ok) return false;
|
||||||
console.warn('[FCM] notification permission denied');
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
return false;
|
return registerFcmTokenOnly();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initialized) {
|
|
||||||
await PushNotifications.register();
|
|
||||||
initialized = true;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unregisterFcmPush(): Promise<void> {
|
export async function unregisterFcmPush(): Promise<void> {
|
||||||
if (!Capacitor.isNativePlatform()) return;
|
if (!Capacitor.isNativePlatform()) return;
|
||||||
try {
|
try {
|
||||||
await deleteFcmToken();
|
await deleteFcmToken();
|
||||||
|
if (Capacitor.getPlatform() === 'android' && (await userOptedIn())) {
|
||||||
|
await GoldenFcm.unregister();
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
initialized = false;
|
tokenRegistered = false;
|
||||||
|
listenersAttached = false;
|
||||||
|
await storageRemove(FCM_REGISTERED_KEY);
|
||||||
|
await storageRemove(FCM_USER_OPT_IN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt'> {
|
export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unknown'> {
|
||||||
if (!Capacitor.isNativePlatform()) return 'prompt';
|
if (!Capacitor.isNativePlatform()) return 'unknown';
|
||||||
const perm = await PushNotifications.checkPermissions();
|
try {
|
||||||
|
if (Capacitor.getPlatform() !== 'android') return 'unknown';
|
||||||
|
const perm = await GoldenFcm.checkPermissions();
|
||||||
if (perm.receive === 'granted') return 'granted';
|
if (perm.receive === 'granted') return 'granted';
|
||||||
if (perm.receive === 'denied') return 'denied';
|
if (perm.receive === 'denied') return 'denied';
|
||||||
return 'prompt';
|
return 'prompt';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const frontendRoot = path.resolve(__dirname, '../frontend/src');
|
|||||||
const sharedRoot = path.resolve(__dirname, '../packages/shared/src');
|
const sharedRoot = path.resolve(__dirname, '../packages/shared/src');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
envDir: __dirname,
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ['react', 'react-dom', 'lightweight-charts'],
|
dedupe: ['react', 'react-dom', 'lightweight-charts'],
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ const TrendSearchPage: React.FC<Props> = ({
|
|||||||
void add(row.market, {
|
void add(row.market, {
|
||||||
koreanName,
|
koreanName,
|
||||||
englishName,
|
englishName,
|
||||||
candleType: filtersRef.current.timeframe,
|
|
||||||
});
|
});
|
||||||
}, [add]);
|
}, [add]);
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ import {
|
|||||||
type VirtualSessionConfig,
|
type VirtualSessionConfig,
|
||||||
type VirtualTargetItem,
|
type VirtualTargetItem,
|
||||||
type VirtualCardViewMode,
|
type VirtualCardViewMode,
|
||||||
resolveTargetCandleType,
|
|
||||||
} from '../utils/virtualTradingStorage';
|
} from '../utils/virtualTradingStorage';
|
||||||
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
|
|
||||||
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
||||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||||
import {
|
import {
|
||||||
@@ -179,7 +177,11 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
})),
|
})),
|
||||||
[targets, session.globalStrategyId]);
|
[targets, session.globalStrategyId]);
|
||||||
|
|
||||||
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
|
const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots(
|
||||||
|
targetRefs,
|
||||||
|
strategies,
|
||||||
|
session.running,
|
||||||
|
);
|
||||||
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
||||||
targetRefs,
|
targetRefs,
|
||||||
session.running,
|
session.running,
|
||||||
@@ -341,59 +343,6 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => {
|
|
||||||
const normalized = normalizeStartCandleType(candleType);
|
|
||||||
setTargets(prev => prev.map(t =>
|
|
||||||
t.market === market ? { ...t, candleType: normalized } : t,
|
|
||||||
));
|
|
||||||
if (!session.running) return;
|
|
||||||
const target = targets.find(t => t.market === market);
|
|
||||||
const strategyId = target
|
|
||||||
? resolveVirtualTargetStrategyId(target, session.globalStrategyId)
|
|
||||||
: session.globalStrategyId;
|
|
||||||
if (!strategyId) return;
|
|
||||||
try {
|
|
||||||
await saveLiveStrategySettings({
|
|
||||||
market,
|
|
||||||
strategyId,
|
|
||||||
isLiveCheck: true,
|
|
||||||
executionType: session.executionType,
|
|
||||||
positionMode: session.positionMode,
|
|
||||||
skipWatchlistSync: true,
|
|
||||||
skipGlobalTemplate: true,
|
|
||||||
});
|
|
||||||
if (strategyId) {
|
|
||||||
await pinStrategyEvaluationTimeframes(market, strategyId);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
window.alert('종목 평가 분봉 변경 저장에 실패했습니다.');
|
|
||||||
}
|
|
||||||
}, [session, targets]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const missing = targets.filter(t => !t.candleType);
|
|
||||||
if (missing.length === 0) return;
|
|
||||||
void Promise.all(
|
|
||||||
missing.map(async t => {
|
|
||||||
try {
|
|
||||||
const s = await loadLiveStrategySettings(t.market);
|
|
||||||
return { market: t.market, candleType: s.candleType };
|
|
||||||
} catch {
|
|
||||||
return { market: t.market, candleType: undefined };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
).then(rows => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setTargets(prev => prev.map(t => {
|
|
||||||
const row = rows.find(r => r.market === t.market);
|
|
||||||
if (t.candleType || !row?.candleType) return t;
|
|
||||||
return { ...t, candleType: normalizeStartCandleType(row.candleType) };
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]);
|
|
||||||
|
|
||||||
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
||||||
if (!session.running || targets.length === 0) return;
|
if (!session.running || targets.length === 0) return;
|
||||||
try {
|
try {
|
||||||
@@ -483,9 +432,9 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
targets={targets}
|
targets={targets}
|
||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
snapshots={snapshots}
|
snapshots={snapshots}
|
||||||
|
snapshotLoadingByMarket={snapshotLoadingByMarket}
|
||||||
session={session}
|
session={session}
|
||||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||||
onTargetCandleTypeChange={(market, ct) => void handleTargetCandleTypeChange(market, ct)}
|
|
||||||
liveStatusByMarket={mergedLiveStatus}
|
liveStatusByMarket={mergedLiveStatus}
|
||||||
lastTickAtByMarket={lastTickAtByMarket}
|
lastTickAtByMarket={lastTickAtByMarket}
|
||||||
selectedMarket={selectedMarket}
|
selectedMarket={selectedMarket}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSn
|
|||||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||||
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
||||||
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
|
||||||
import { normalizeStartCandleType } from '../../utils/strategyStartNodes';
|
|
||||||
import type { Timeframe } from '../../types';
|
import type { Timeframe } from '../../types';
|
||||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||||
@@ -27,9 +26,8 @@ interface Props {
|
|||||||
strategyId: number | null;
|
strategyId: number | null;
|
||||||
globalStrategyId: number | null;
|
globalStrategyId: number | null;
|
||||||
onStrategyChange?: (strategyId: number | null) => void;
|
onStrategyChange?: (strategyId: number | null) => void;
|
||||||
candleType: string;
|
|
||||||
onCandleTypeChange?: (candleType: string) => void;
|
|
||||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||||
|
signalLoading?: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
liveStatus?: VirtualLiveStatus;
|
liveStatus?: VirtualLiveStatus;
|
||||||
lastTickAt?: number;
|
lastTickAt?: number;
|
||||||
@@ -54,9 +52,8 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
strategyId,
|
strategyId,
|
||||||
globalStrategyId,
|
globalStrategyId,
|
||||||
onStrategyChange,
|
onStrategyChange,
|
||||||
candleType,
|
|
||||||
onCandleTypeChange,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
|
signalLoading = false,
|
||||||
running,
|
running,
|
||||||
liveStatus = 'idle',
|
liveStatus = 'idle',
|
||||||
lastTickAt,
|
lastTickAt,
|
||||||
@@ -83,8 +80,10 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
|
|
||||||
const isDetail = viewMode === 'detail';
|
const isDetail = viewMode === 'detail';
|
||||||
const isChart = displayMode === 'chart';
|
const isChart = displayMode === 'chart';
|
||||||
const evalCandleType = normalizeStartCandleType(candleType);
|
const chartTimeframe = useMemo(
|
||||||
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
|
() => resolveStrategyPrimaryTimeframe(strategy) as Timeframe,
|
||||||
|
[strategy],
|
||||||
|
);
|
||||||
|
|
||||||
const receiveSignal = useMemo(() => {
|
const receiveSignal = useMemo(() => {
|
||||||
if (!running) return null;
|
if (!running) return null;
|
||||||
@@ -184,6 +183,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
receiving={highlightReceiving}
|
receiving={highlightReceiving}
|
||||||
|
loading={signalLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -192,9 +192,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
strategyId={strategyId}
|
strategyId={strategyId}
|
||||||
globalStrategyId={globalStrategyId}
|
globalStrategyId={globalStrategyId}
|
||||||
candleType={evalCandleType}
|
|
||||||
onStrategyChange={onStrategyChange}
|
onStrategyChange={onStrategyChange}
|
||||||
onCandleTypeChange={onCandleTypeChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ interface Props {
|
|||||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||||
/** 전체보기 분할 pane — 캔버스 높이 100% */
|
/** 전체보기 분할 pane — 캔버스 높이 100% */
|
||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
/** 카드 푸터 평가 분봉과 동기화 */
|
/** 미지정 시 전략 DSL 대표 분봉 사용 */
|
||||||
chartTimeframe?: Timeframe;
|
chartTimeframe?: Timeframe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import type { StrategyDto } from '../../utils/backendApi';
|
import type { StrategyDto } from '../../utils/backendApi';
|
||||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
import { formatUpdatedTime } from '../../utils/virtualSignalMetrics';
|
import { formatUpdatedTime } from '../../utils/virtualSignalMetrics';
|
||||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
|
|
||||||
import {
|
import {
|
||||||
defaultStrategyOptionLabel,
|
defaultStrategyOptionLabel,
|
||||||
parseTargetStrategySelectValue,
|
parseTargetStrategySelectValue,
|
||||||
@@ -14,20 +13,16 @@ interface Props {
|
|||||||
strategies: StrategyDto[];
|
strategies: StrategyDto[];
|
||||||
strategyId: number | null;
|
strategyId: number | null;
|
||||||
globalStrategyId: number | null;
|
globalStrategyId: number | null;
|
||||||
candleType: string;
|
|
||||||
onStrategyChange?: (strategyId: number | null) => void;
|
onStrategyChange?: (strategyId: number | null) => void;
|
||||||
onCandleTypeChange?: (candleType: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드 하단 — 갱신 시각(좌) · 전략·시간봉 드롭다운(우) */
|
/** 카드 하단 — 갱신 시각(좌) · 전략 드롭다운(우) */
|
||||||
const VirtualTargetCardFoot: React.FC<Props> = ({
|
const VirtualTargetCardFoot: React.FC<Props> = ({
|
||||||
snapshot,
|
snapshot,
|
||||||
strategies,
|
strategies,
|
||||||
strategyId,
|
strategyId,
|
||||||
globalStrategyId,
|
globalStrategyId,
|
||||||
candleType,
|
|
||||||
onStrategyChange,
|
onStrategyChange,
|
||||||
onCandleTypeChange,
|
|
||||||
}) => (
|
}) => (
|
||||||
<div className="vtd-card-foot">
|
<div className="vtd-card-foot">
|
||||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||||
@@ -49,18 +44,6 @@ const VirtualTargetCardFoot: React.FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="vtd-card-foot-field">
|
|
||||||
<select
|
|
||||||
className="vtd-card-foot-select vtd-card-foot-select--tf"
|
|
||||||
aria-label="시간봉"
|
|
||||||
value={candleType}
|
|
||||||
onChange={e => onCandleTypeChange?.(e.target.value)}
|
|
||||||
>
|
|
||||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import VirtualTargetQuote from './VirtualTargetQuote';
|
|||||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||||
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||||
import VirtualTargetCardFoot from './VirtualTargetCardFoot';
|
import VirtualTargetCardFoot from './VirtualTargetCardFoot';
|
||||||
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
|
||||||
import { normalizeStartCandleType } from '../../utils/strategyStartNodes';
|
|
||||||
import type { Timeframe } from '../../types';
|
import type { Timeframe } from '../../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,9 +23,8 @@ interface Props {
|
|||||||
strategyId: number | null;
|
strategyId: number | null;
|
||||||
globalStrategyId: number | null;
|
globalStrategyId: number | null;
|
||||||
onStrategyChange?: (strategyId: number | null) => void;
|
onStrategyChange?: (strategyId: number | null) => void;
|
||||||
candleType: string;
|
|
||||||
onCandleTypeChange?: (candleType: string) => void;
|
|
||||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||||
|
signalLoading?: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
liveStatus?: VirtualLiveStatus;
|
liveStatus?: VirtualLiveStatus;
|
||||||
lastTickAt?: number;
|
lastTickAt?: number;
|
||||||
@@ -48,9 +46,8 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
|||||||
strategyId,
|
strategyId,
|
||||||
globalStrategyId,
|
globalStrategyId,
|
||||||
onStrategyChange,
|
onStrategyChange,
|
||||||
candleType,
|
|
||||||
onCandleTypeChange,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
|
signalLoading = false,
|
||||||
running,
|
running,
|
||||||
liveStatus = 'idle',
|
liveStatus = 'idle',
|
||||||
lastTickAt,
|
lastTickAt,
|
||||||
@@ -72,8 +69,10 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
|||||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||||
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||||
const evalCandleType = normalizeStartCandleType(candleType);
|
const chartTimeframe = useMemo(
|
||||||
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
|
() => resolveStrategyPrimaryTimeframe(strategy) as Timeframe,
|
||||||
|
[strategy],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`vtd-focus-wrap${highlightReceiving ? ' vtd-focus-wrap--receiving' : ''}`}>
|
<div className={`vtd-focus-wrap${highlightReceiving ? ' vtd-focus-wrap--receiving' : ''}`}>
|
||||||
@@ -129,6 +128,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
|||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
receiving={highlightReceiving}
|
receiving={highlightReceiving}
|
||||||
|
loading={signalLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -139,9 +139,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
|||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
strategyId={strategyId}
|
strategyId={strategyId}
|
||||||
globalStrategyId={globalStrategyId}
|
globalStrategyId={globalStrategyId}
|
||||||
candleType={evalCandleType}
|
|
||||||
onStrategyChange={onStrategyChange}
|
onStrategyChange={onStrategyChange}
|
||||||
onCandleTypeChange={onCandleTypeChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
type VirtualTargetItem,
|
type VirtualTargetItem,
|
||||||
type VirtualCardViewMode,
|
type VirtualCardViewMode,
|
||||||
type VirtualSessionConfig,
|
type VirtualSessionConfig,
|
||||||
resolveTargetCandleType,
|
|
||||||
} from '../../utils/virtualTradingStorage';
|
} from '../../utils/virtualTradingStorage';
|
||||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
@@ -19,9 +18,9 @@ interface Props {
|
|||||||
targets: VirtualTargetItem[];
|
targets: VirtualTargetItem[];
|
||||||
strategies: StrategyDto[];
|
strategies: StrategyDto[];
|
||||||
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||||
|
snapshotLoadingByMarket?: Record<string, boolean>;
|
||||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||||
onTargetCandleTypeChange: (market: string, candleType: string) => void;
|
|
||||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||||
lastTickAtByMarket?: Record<string, number>;
|
lastTickAtByMarket?: Record<string, number>;
|
||||||
selectedMarket?: string;
|
selectedMarket?: string;
|
||||||
@@ -38,9 +37,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VirtualTargetGrid: React.FC<Props> = ({
|
const VirtualTargetGrid: React.FC<Props> = ({
|
||||||
targets, strategies, snapshots, session,
|
targets, strategies, snapshots, snapshotLoadingByMarket = {}, session,
|
||||||
onTargetStrategyChange,
|
onTargetStrategyChange,
|
||||||
onTargetCandleTypeChange,
|
|
||||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
||||||
onSelectMarket,
|
onSelectMarket,
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
@@ -119,9 +117,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
strategyId={focusTarget.strategyId}
|
strategyId={focusTarget.strategyId}
|
||||||
globalStrategyId={session.globalStrategyId}
|
globalStrategyId={session.globalStrategyId}
|
||||||
onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)}
|
onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)}
|
||||||
candleType={resolveTargetCandleType(focusTarget, snapshots[focusTarget.market]?.timeframe)}
|
|
||||||
onCandleTypeChange={ct => onTargetCandleTypeChange(focusTarget.market, ct)}
|
|
||||||
snapshot={snapshots[focusTarget.market]}
|
snapshot={snapshots[focusTarget.market]}
|
||||||
|
signalLoading={snapshotLoadingByMarket[focusTarget.market]}
|
||||||
running={session.running}
|
running={session.running}
|
||||||
liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')}
|
liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||||
lastTickAt={lastTickAtByMarket[focusTarget.market]}
|
lastTickAt={lastTickAtByMarket[focusTarget.market]}
|
||||||
@@ -147,9 +144,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
strategyId={t.strategyId}
|
strategyId={t.strategyId}
|
||||||
globalStrategyId={session.globalStrategyId}
|
globalStrategyId={session.globalStrategyId}
|
||||||
onStrategyChange={id => onTargetStrategyChange(t.market, id)}
|
onStrategyChange={id => onTargetStrategyChange(t.market, id)}
|
||||||
candleType={resolveTargetCandleType(t, snapshots[t.market]?.timeframe)}
|
|
||||||
onCandleTypeChange={ct => onTargetCandleTypeChange(t.market, ct)}
|
|
||||||
snapshot={snapshots[t.market]}
|
snapshot={snapshots[t.market]}
|
||||||
|
signalLoading={snapshotLoadingByMarket[t.market]}
|
||||||
running={session.running}
|
running={session.running}
|
||||||
liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
|
liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||||
lastTickAt={lastTickAtByMarket[t.market]}
|
lastTickAt={lastTickAtByMarket[t.market]}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface Props {
|
|||||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||||
viewMode?: VirtualCardViewMode;
|
viewMode?: VirtualCardViewMode;
|
||||||
receiving?: boolean;
|
receiving?: boolean;
|
||||||
|
/** 업비트 캔들 warm-up · live-conditions 수집 중 */
|
||||||
|
loading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
|||||||
snapshot,
|
snapshot,
|
||||||
viewMode = 'summary',
|
viewMode = 'summary',
|
||||||
receiving = false,
|
receiving = false,
|
||||||
|
loading = false,
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const rows = snapshot?.rows ?? [];
|
const rows = snapshot?.rows ?? [];
|
||||||
@@ -36,8 +39,10 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className={`vtd-muted vtd-card-empty${className ? ` ${className}` : ''}`}>
|
<p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}>
|
||||||
전략 조건·지표 데이터 없음
|
{loading
|
||||||
|
? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)'
|
||||||
|
: '전략 조건·지표 데이터 없음'}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrat
|
|||||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||||
let _cache: AppSettingsDto | null = null;
|
let _cache: AppSettingsDto | null = null;
|
||||||
let _loadPromise: Promise<AppSettingsDto> | null = null;
|
let _loadPromise: Promise<AppSettingsDto> | null = null;
|
||||||
|
/** invalidate 이후 완료된 로드만 캐시에 반영 */
|
||||||
|
let _loadGeneration = 0;
|
||||||
type AppSettingsListener = () => void;
|
type AppSettingsListener = () => void;
|
||||||
const _listeners = new Set<AppSettingsListener>();
|
const _listeners = new Set<AppSettingsListener>();
|
||||||
|
|
||||||
@@ -74,20 +76,31 @@ export function subscribeAppSettings(listener: AppSettingsListener): () => void
|
|||||||
function ensureLoaded(): Promise<AppSettingsDto> {
|
function ensureLoaded(): Promise<AppSettingsDto> {
|
||||||
if (_cache !== null) return Promise.resolve(_cache);
|
if (_cache !== null) return Promise.resolve(_cache);
|
||||||
if (_loadPromise) return _loadPromise;
|
if (_loadPromise) return _loadPromise;
|
||||||
|
const generation = _loadGeneration;
|
||||||
_loadPromise = loadAppSettings().then(async data => {
|
_loadPromise = loadAppSettings().then(async data => {
|
||||||
|
if (generation !== _loadGeneration) {
|
||||||
|
_loadPromise = null;
|
||||||
|
return _cache ?? {};
|
||||||
|
}
|
||||||
_cache = data ?? {};
|
_cache = data ?? {};
|
||||||
const migrated = migrateLocalStorageToUiPreferences(_cache);
|
const migrated = migrateLocalStorageToUiPreferences(_cache);
|
||||||
if (migrated?.changed) {
|
if (migrated?.changed) {
|
||||||
try {
|
try {
|
||||||
const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences });
|
const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences });
|
||||||
|
if (generation === _loadGeneration) {
|
||||||
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
|
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e);
|
console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e);
|
||||||
|
if (generation === _loadGeneration) {
|
||||||
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
|
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_loadPromise = null;
|
_loadPromise = null;
|
||||||
|
if (generation === _loadGeneration) {
|
||||||
notifyAppSettingsListeners();
|
notifyAppSettingsListeners();
|
||||||
|
}
|
||||||
return _cache;
|
return _cache;
|
||||||
});
|
});
|
||||||
return _loadPromise;
|
return _loadPromise;
|
||||||
@@ -96,6 +109,7 @@ function ensureLoaded(): Promise<AppSettingsDto> {
|
|||||||
export function invalidateAppSettingsCache() {
|
export function invalidateAppSettingsCache() {
|
||||||
_cache = null;
|
_cache = null;
|
||||||
_loadPromise = null;
|
_loadPromise = null;
|
||||||
|
_loadGeneration += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */
|
/** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */
|
||||||
@@ -167,13 +181,32 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
|||||||
/** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */
|
/** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */
|
||||||
export function useAppSettings(sessionKey = 0) {
|
export function useAppSettings(sessionKey = 0) {
|
||||||
const [settings, setSettings] = useState<AppSettingsDto>(_cache ?? {});
|
const [settings, setSettings] = useState<AppSettingsDto>(_cache ?? {});
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(_cache !== null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const prevSessionKeyRef = useRef(sessionKey);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
|
const sessionChanged = prevSessionKeyRef.current !== sessionKey;
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
|
|
||||||
|
if (sessionChanged) {
|
||||||
|
if (_cache !== null) {
|
||||||
|
setSettings(_cache);
|
||||||
|
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||||
|
setIsLoaded(true);
|
||||||
|
} else {
|
||||||
setIsLoaded(false);
|
setIsLoaded(false);
|
||||||
invalidateAppSettingsCache();
|
invalidateAppSettingsCache();
|
||||||
|
}
|
||||||
|
} else if (_cache !== null) {
|
||||||
|
setSettings(_cache);
|
||||||
|
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||||
|
setIsLoaded(true);
|
||||||
|
} else {
|
||||||
|
setIsLoaded(false);
|
||||||
|
}
|
||||||
|
|
||||||
ensureLoaded().then(data => {
|
ensureLoaded().then(data => {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 가상투자 카드 — 종목×전략별 지표·조건 스냅샷
|
* 가상투자 카드 — 종목×전략별 지표·조건 스냅샷
|
||||||
* running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립)
|
* 전략 지정 시: candles/watch pin(업비트 warm-up) + live-conditions API
|
||||||
|
* running 시 3초 폴링, 미수집 시 재시도
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { StrategyDto } from '../utils/backendApi';
|
import type { StrategyDto } from '../utils/backendApi';
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
pinCandleWatch,
|
pinCandleWatch,
|
||||||
type LiveConditionRowDto,
|
type LiveConditionRowDto,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
|
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
||||||
import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry';
|
import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry';
|
||||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +29,12 @@ export interface VirtualIndicatorSnapshot {
|
|||||||
matchRate?: number;
|
matchRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VirtualIndicatorSnapshotsState {
|
||||||
|
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||||
|
/** 업비트 캔들·live-conditions 수집 중 */
|
||||||
|
loadingByMarket: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'indicatorType' | 'conditionType' | 'targetValue' | 'plotKey'>): string {
|
function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'indicatorType' | 'conditionType' | 'targetValue' | 'plotKey'>): string {
|
||||||
return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`;
|
return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`;
|
||||||
}
|
}
|
||||||
@@ -84,16 +92,11 @@ function mergeRows(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildSnapshot(
|
function staticSnapshot(
|
||||||
market: string,
|
market: string,
|
||||||
strategyId: number,
|
strategyId: number,
|
||||||
strategy: StrategyDto | undefined,
|
baseRows: VirtualConditionRow[],
|
||||||
running: boolean,
|
): VirtualIndicatorSnapshot {
|
||||||
): Promise<VirtualIndicatorSnapshot | null> {
|
|
||||||
const baseRows = strategy ? extractVirtualConditions(strategy) : [];
|
|
||||||
|
|
||||||
if (!running) {
|
|
||||||
if (baseRows.length === 0) return null;
|
|
||||||
return {
|
return {
|
||||||
market,
|
market,
|
||||||
strategyId,
|
strategyId,
|
||||||
@@ -102,6 +105,25 @@ async function buildSnapshot(
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
matchRate: 0,
|
matchRate: 0,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */
|
||||||
|
async function fetchLiveSnapshot(
|
||||||
|
market: string,
|
||||||
|
strategyId: number,
|
||||||
|
strategy: StrategyDto | undefined,
|
||||||
|
pinFirst: boolean,
|
||||||
|
): Promise<VirtualIndicatorSnapshot | null> {
|
||||||
|
const baseRows = strategy ? extractVirtualConditions(strategy) : [];
|
||||||
|
|
||||||
|
if (pinFirst) {
|
||||||
|
try {
|
||||||
|
await pinStrategyEvaluationTimeframes(market, strategyId);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await pinCandleWatch(market, '1m');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let status: Awaited<ReturnType<typeof fetchLiveConditionStatus>> = null;
|
let status: Awaited<ReturnType<typeof fetchLiveConditionStatus>> = null;
|
||||||
@@ -110,23 +132,20 @@ async function buildSnapshot(
|
|||||||
} catch {
|
} catch {
|
||||||
status = null;
|
status = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!status || status.market !== market || status.strategyId !== strategyId) {
|
if (!status || status.market !== market || status.strategyId !== strategyId) {
|
||||||
if (baseRows.length === 0) return null;
|
if (baseRows.length === 0) return null;
|
||||||
return {
|
return staticSnapshot(market, strategyId, baseRows);
|
||||||
market,
|
|
||||||
strategyId,
|
|
||||||
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
|
||||||
rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
matchRate: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rows = mergeRows(baseRows, status.rows);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
market,
|
market,
|
||||||
strategyId,
|
strategyId,
|
||||||
timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
||||||
rows: mergeRows(baseRows, status.rows),
|
rows,
|
||||||
updatedAt: status.updatedAt || Date.now(),
|
updatedAt: status.updatedAt || Date.now(),
|
||||||
matchRate: coerceFiniteNumber(status.matchRate) ?? 0,
|
matchRate: coerceFiniteNumber(status.matchRate) ?? 0,
|
||||||
};
|
};
|
||||||
@@ -142,12 +161,14 @@ export function useVirtualIndicatorSnapshots(
|
|||||||
strategies: StrategyDto[],
|
strategies: StrategyDto[],
|
||||||
running: boolean,
|
running: boolean,
|
||||||
pollMs = 3000,
|
pollMs = 3000,
|
||||||
): Record<string, VirtualIndicatorSnapshot> {
|
): VirtualIndicatorSnapshotsState {
|
||||||
const [snapshots, setSnapshots] = useState<Record<string, VirtualIndicatorSnapshot>>({});
|
const [snapshots, setSnapshots] = useState<Record<string, VirtualIndicatorSnapshot>>({});
|
||||||
|
const [loadingByMarket, setLoadingByMarket] = useState<Record<string, boolean>>({});
|
||||||
const strategiesRef = useRef(strategies);
|
const strategiesRef = useRef(strategies);
|
||||||
strategiesRef.current = strategies;
|
strategiesRef.current = strategies;
|
||||||
const pinnedRef = useRef<Set<string>>(new Set());
|
const pinnedRef = useRef<Set<string>>(new Set());
|
||||||
const refreshGenRef = useRef<Record<string, number>>({});
|
const refreshGenRef = useRef<Record<string, number>>({});
|
||||||
|
const emptyRetryRef = useRef<Record<string, number>>({});
|
||||||
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
|
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
|
||||||
|
|
||||||
const pinTargets = useCallback(async () => {
|
const pinTargets = useCallback(async () => {
|
||||||
@@ -155,6 +176,10 @@ export function useVirtualIndicatorSnapshots(
|
|||||||
const pins = new Set<string>();
|
const pins = new Set<string>();
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
if (t.strategyId == null) continue;
|
if (t.strategyId == null) continue;
|
||||||
|
try {
|
||||||
|
const tfs = await pinStrategyEvaluationTimeframes(t.market, t.strategyId);
|
||||||
|
for (const tf of tfs) pins.add(`${t.market}:${tf}`);
|
||||||
|
} catch {
|
||||||
const strat = strats.find(s => s.id === t.strategyId);
|
const strat = strats.find(s => s.id === t.strategyId);
|
||||||
const conditions = strat ? extractVirtualConditions(strat) : [];
|
const conditions = strat ? extractVirtualConditions(strat) : [];
|
||||||
const tfs = conditions.length > 0
|
const tfs = conditions.length > 0
|
||||||
@@ -169,7 +194,7 @@ export function useVirtualIndicatorSnapshots(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const k1 = `${t.market}:1m`;
|
const k1 = `${t.market}:1m`;
|
||||||
if (!pins.has(k1) || !tfs.includes('1m')) {
|
pins.add(k1);
|
||||||
if (!pinnedRef.current.has(k1)) {
|
if (!pinnedRef.current.has(k1)) {
|
||||||
await pinCandleWatch(t.market, '1m');
|
await pinCandleWatch(t.market, '1m');
|
||||||
pinnedRef.current.add(k1);
|
pinnedRef.current.add(k1);
|
||||||
@@ -183,18 +208,48 @@ export function useVirtualIndicatorSnapshots(
|
|||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const strats = strategiesRef.current;
|
const strats = strategiesRef.current;
|
||||||
if (running) await pinTargets();
|
const activeTargets = targets.filter(t => t.strategyId != null);
|
||||||
|
if (activeTargets.length === 0) {
|
||||||
|
setSnapshots({});
|
||||||
|
setLoadingByMarket({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const jobs = targets
|
setLoadingByMarket(prev => {
|
||||||
.filter(t => t.strategyId != null)
|
const next = { ...prev };
|
||||||
.map(async t => {
|
for (const t of activeTargets) next[t.market] = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
await pinTargets();
|
||||||
|
|
||||||
|
const jobs = activeTargets.map(async t => {
|
||||||
const gen = (refreshGenRef.current[t.market] ?? 0) + 1;
|
const gen = (refreshGenRef.current[t.market] ?? 0) + 1;
|
||||||
refreshGenRef.current[t.market] = gen;
|
refreshGenRef.current[t.market] = gen;
|
||||||
const strat = strats.find(s => s.id === t.strategyId);
|
const strat = strats.find(s => s.id === t.strategyId);
|
||||||
const snap = await buildSnapshot(t.market, t.strategyId!, strat, running);
|
const baseRows = strat ? extractVirtualConditions(strat) : [];
|
||||||
|
const needsLiveApi = running || baseRows.length === 0;
|
||||||
|
|
||||||
|
let snap: VirtualIndicatorSnapshot | null = null;
|
||||||
|
if (needsLiveApi) {
|
||||||
|
const retry = emptyRetryRef.current[t.market] ?? 0;
|
||||||
|
snap = await fetchLiveSnapshot(t.market, t.strategyId!, strat, retry === 0);
|
||||||
|
} else {
|
||||||
|
snap = staticSnapshot(t.market, t.strategyId!, baseRows);
|
||||||
|
}
|
||||||
|
|
||||||
if (!snap || refreshGenRef.current[t.market] !== gen) return snap;
|
if (!snap || refreshGenRef.current[t.market] !== gen) return snap;
|
||||||
if (snap.strategyId !== t.strategyId) return snap;
|
if (snap.strategyId !== t.strategyId) return snap;
|
||||||
|
|
||||||
|
if (snap.rows.length === 0) {
|
||||||
|
const n = (emptyRetryRef.current[t.market] ?? 0) + 1;
|
||||||
|
emptyRetryRef.current[t.market] = n;
|
||||||
|
} else {
|
||||||
|
emptyRetryRef.current[t.market] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
|
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
|
||||||
|
setLoadingByMarket(prev => ({ ...prev, [t.market]: false }));
|
||||||
return snap;
|
return snap;
|
||||||
});
|
});
|
||||||
await Promise.all(jobs);
|
await Promise.all(jobs);
|
||||||
@@ -211,20 +266,36 @@ export function useVirtualIndicatorSnapshots(
|
|||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
|
setLoadingByMarket(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
let changed = false;
|
||||||
|
for (const key of Object.keys(next)) {
|
||||||
|
if (!activeMarkets.has(key)) {
|
||||||
|
delete next[key];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
}, [targets, running, pinTargets]);
|
}, [targets, running, pinTargets]);
|
||||||
|
|
||||||
|
const hasStrategyTargets = targets.some(t => t.strategyId != null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
setSnapshots({});
|
setSnapshots({});
|
||||||
|
setLoadingByMarket({});
|
||||||
pinnedRef.current.clear();
|
pinnedRef.current.clear();
|
||||||
refreshGenRef.current = {};
|
refreshGenRef.current = {};
|
||||||
|
emptyRetryRef.current = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
void refresh();
|
||||||
if (!running) return;
|
if (!running && !hasStrategyTargets) return;
|
||||||
const id = window.setInterval(() => void refresh(), pollMs);
|
const intervalMs = running ? pollMs : 5000;
|
||||||
|
const id = window.setInterval(() => void refresh(), intervalMs);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [targetsKey, running, pollMs, refresh]);
|
}, [targetsKey, running, hasStrategyTargets, pollMs, refresh]);
|
||||||
|
|
||||||
return snapshots;
|
return { snapshots, loadingByMarket };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface VirtualTargetLiveState {
|
|||||||
export function useVirtualTargetLiveStatus(
|
export function useVirtualTargetLiveStatus(
|
||||||
targets: TargetRef[],
|
targets: TargetRef[],
|
||||||
running: boolean,
|
running: boolean,
|
||||||
|
/** 전략 지정만 된 상태에서도 STOMP·pin으로 업비트 틱 수신 (지표 warm-up 보조) */
|
||||||
|
eagerConnect = true,
|
||||||
): VirtualTargetLiveState {
|
): VirtualTargetLiveState {
|
||||||
const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({});
|
const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({});
|
||||||
const [lastTickAtByMarket, setLastTickAtByMarket] = useState<Record<string, number>>({});
|
const [lastTickAtByMarket, setLastTickAtByMarket] = useState<Record<string, number>>({});
|
||||||
@@ -57,7 +59,7 @@ export function useVirtualTargetLiveStatus(
|
|||||||
|
|
||||||
const recomputeStatus = (market: string) => {
|
const recomputeStatus = (market: string) => {
|
||||||
const s = stateRef.current[market];
|
const s = stateRef.current[market];
|
||||||
if (!s || !running) return;
|
if (!s) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) {
|
if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) {
|
||||||
if (s.status !== 'live') patchMarket(market, { status: 'live' });
|
if (s.status !== 'live') patchMarket(market, { status: 'live' });
|
||||||
@@ -71,17 +73,18 @@ export function useVirtualTargetLiveStatus(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!running) {
|
const activeMarkets = targets
|
||||||
|
.filter(t => t.strategyId != null)
|
||||||
|
.map(t => t.market);
|
||||||
|
|
||||||
|
const active = running || (eagerConnect && activeMarkets.length > 0);
|
||||||
|
if (!active) {
|
||||||
stateRef.current = {};
|
stateRef.current = {};
|
||||||
setByMarket({});
|
setByMarket({});
|
||||||
setLastTickAtByMarket({});
|
setLastTickAtByMarket({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeMarkets = targets
|
|
||||||
.filter(t => t.strategyId != null)
|
|
||||||
.map(t => t.market);
|
|
||||||
|
|
||||||
for (const market of activeMarkets) {
|
for (const market of activeMarkets) {
|
||||||
if (!stateRef.current[market]) {
|
if (!stateRef.current[market]) {
|
||||||
stateRef.current[market] = { status: 'connecting', lastTickAt: null };
|
stateRef.current[market] = { status: 'connecting', lastTickAt: null };
|
||||||
@@ -129,7 +132,7 @@ export function useVirtualTargetLiveStatus(
|
|||||||
offConn();
|
offConn();
|
||||||
clearInterval(staleId);
|
clearInterval(staleId);
|
||||||
};
|
};
|
||||||
}, [targets, running]);
|
}, [targets, running, eagerConnect]);
|
||||||
|
|
||||||
return { statusByMarket: byMarket, lastTickAtByMarket };
|
return { statusByMarket: byMarket, lastTickAtByMarket };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
type VirtualTargetItem,
|
type VirtualTargetItem,
|
||||||
type VirtualCardViewMode,
|
type VirtualCardViewMode,
|
||||||
} from '../utils/virtualTradingStorage';
|
} from '../utils/virtualTradingStorage';
|
||||||
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
|
|
||||||
import {
|
import {
|
||||||
syncVirtualTargetsToBackend,
|
syncVirtualTargetsToBackend,
|
||||||
stopVirtualLiveOnBackend,
|
stopVirtualLiveOnBackend,
|
||||||
@@ -197,7 +196,11 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
[targets, session.globalStrategyId],
|
[targets, session.globalStrategyId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
|
const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots(
|
||||||
|
targetRefs,
|
||||||
|
strategies,
|
||||||
|
session.running,
|
||||||
|
);
|
||||||
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
||||||
targetRefs,
|
targetRefs,
|
||||||
session.running,
|
session.running,
|
||||||
@@ -338,57 +341,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => {
|
|
||||||
const normalized = normalizeStartCandleType(candleType);
|
|
||||||
setTargets(prev => prev.map(t =>
|
|
||||||
t.market === market ? { ...t, candleType: normalized } : t,
|
|
||||||
));
|
|
||||||
if (!session.running) return;
|
|
||||||
const target = targets.find(t => t.market === market);
|
|
||||||
const strategyId = target
|
|
||||||
? resolveVirtualTargetStrategyId(target, session.globalStrategyId)
|
|
||||||
: session.globalStrategyId;
|
|
||||||
if (!strategyId) return;
|
|
||||||
try {
|
|
||||||
await saveLiveStrategySettings({
|
|
||||||
market,
|
|
||||||
strategyId,
|
|
||||||
isLiveCheck: true,
|
|
||||||
executionType: session.executionType,
|
|
||||||
positionMode: session.positionMode,
|
|
||||||
skipWatchlistSync: true,
|
|
||||||
skipGlobalTemplate: true,
|
|
||||||
});
|
|
||||||
await pinStrategyEvaluationTimeframes(market, strategyId);
|
|
||||||
} catch {
|
|
||||||
window.alert('종목 평가 분봉 변경 저장에 실패했습니다.');
|
|
||||||
}
|
|
||||||
}, [session, targets]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const missing = targets.filter(t => !t.candleType);
|
|
||||||
if (missing.length === 0) return;
|
|
||||||
void Promise.all(
|
|
||||||
missing.map(async t => {
|
|
||||||
try {
|
|
||||||
const s = await loadLiveStrategySettings(t.market);
|
|
||||||
return { market: t.market, candleType: s.candleType };
|
|
||||||
} catch {
|
|
||||||
return { market: t.market, candleType: undefined };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
).then(rows => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setTargets(prev => prev.map(t => {
|
|
||||||
const row = rows.find(r => r.market === t.market);
|
|
||||||
if (t.candleType || !row?.candleType) return t;
|
|
||||||
return { ...t, candleType: normalizeStartCandleType(row.candleType) };
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]);
|
|
||||||
|
|
||||||
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
||||||
if (!session.running || targets.length === 0) return;
|
if (!session.running || targets.length === 0) return;
|
||||||
try {
|
try {
|
||||||
@@ -448,6 +400,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
setViewMode,
|
setViewMode,
|
||||||
refreshPaperData,
|
refreshPaperData,
|
||||||
snapshots,
|
snapshots,
|
||||||
|
snapshotLoadingByMarket,
|
||||||
liveStatusByMarket: mergedLiveStatus,
|
liveStatusByMarket: mergedLiveStatus,
|
||||||
lastTickAtByMarket,
|
lastTickAtByMarket,
|
||||||
strategyNames,
|
strategyNames,
|
||||||
@@ -460,7 +413,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
handleExecutionTypeChange,
|
handleExecutionTypeChange,
|
||||||
handlePositionModeChange,
|
handlePositionModeChange,
|
||||||
handleTargetStrategyChange,
|
handleTargetStrategyChange,
|
||||||
handleTargetCandleTypeChange,
|
|
||||||
reloadTargetsFromStorage,
|
reloadTargetsFromStorage,
|
||||||
appChartDefaults,
|
appChartDefaults,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function autoAddTrendSearchTargets(
|
|||||||
const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName);
|
const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName);
|
||||||
targets = await addVirtualTarget(
|
targets = await addVirtualTarget(
|
||||||
row.market,
|
row.market,
|
||||||
{ koreanName, englishName, candleType: timeframe },
|
{ koreanName, englishName },
|
||||||
{ showLimitAlert: false },
|
{ showLimitAlert: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
isVirtualTargetAddAllowed,
|
isVirtualTargetAddAllowed,
|
||||||
virtualTargetLimitMessage,
|
virtualTargetLimitMessage,
|
||||||
} from '../utils/virtualTargetLimits';
|
} from '../utils/virtualTargetLimits';
|
||||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
|
||||||
import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync';
|
import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync';
|
||||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||||
import {
|
import {
|
||||||
@@ -18,8 +17,6 @@ import {
|
|||||||
export interface VirtualTargetMeta {
|
export interface VirtualTargetMeta {
|
||||||
koreanName?: string;
|
koreanName?: string;
|
||||||
englishName?: string;
|
englishName?: string;
|
||||||
/** 추세검색 캔들 주기 → 투자대상 평가 분봉 기본값 */
|
|
||||||
candleType?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddVirtualTargetOptions {
|
export interface AddVirtualTargetOptions {
|
||||||
@@ -51,7 +48,6 @@ export async function addVirtualTarget(
|
|||||||
strategyId: null,
|
strategyId: null,
|
||||||
koreanName: meta.koreanName,
|
koreanName: meta.koreanName,
|
||||||
englishName: en,
|
englishName: en,
|
||||||
candleType: meta.candleType ? normalizeStartCandleType(meta.candleType) : undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = [...targets, item];
|
const next = [...targets, item];
|
||||||
@@ -64,7 +60,6 @@ export async function addVirtualTarget(
|
|||||||
isPinned: false,
|
isPinned: false,
|
||||||
executionType: session.executionType,
|
executionType: session.executionType,
|
||||||
positionMode: session.positionMode,
|
positionMode: session.positionMode,
|
||||||
candleType: item.candleType ?? '1m',
|
|
||||||
skipWatchlistSync: true,
|
skipWatchlistSync: true,
|
||||||
skipGlobalTemplate: true,
|
skipGlobalTemplate: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ function fromLiveSettings(
|
|||||||
return {
|
return {
|
||||||
market: s.market,
|
market: s.market,
|
||||||
strategyId: s.strategyId ?? globalStrategyId,
|
strategyId: s.strategyId ?? globalStrategyId,
|
||||||
candleType: s.candleType,
|
|
||||||
koreanName,
|
koreanName,
|
||||||
englishName,
|
englishName,
|
||||||
pinned: !!s.isPinned,
|
pinned: !!s.isPinned,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
|
||||||
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||||
|
|
||||||
@@ -7,8 +6,6 @@ import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
|||||||
export interface VirtualTargetItem {
|
export interface VirtualTargetItem {
|
||||||
market: string;
|
market: string;
|
||||||
strategyId: number | null;
|
strategyId: number | null;
|
||||||
/** 종목별 전략 평가 분봉 (gc_live_strategy_settings.candle_type) */
|
|
||||||
candleType?: string;
|
|
||||||
koreanName?: string;
|
koreanName?: string;
|
||||||
englishName?: string;
|
englishName?: string;
|
||||||
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */
|
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */
|
||||||
@@ -35,6 +32,7 @@ function defaultSession(): VirtualSessionConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 레거시 ui_preferences.virtual.targets.candleType 제거 */
|
||||||
function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
||||||
return items.map(t => {
|
return items.map(t => {
|
||||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||||
@@ -42,7 +40,13 @@ function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
|||||||
t.koreanName,
|
t.koreanName,
|
||||||
t.englishName,
|
t.englishName,
|
||||||
);
|
);
|
||||||
return { ...t, koreanName, englishName };
|
return {
|
||||||
|
market: t.market,
|
||||||
|
strategyId: t.strategyId ?? null,
|
||||||
|
koreanName,
|
||||||
|
englishName,
|
||||||
|
pinned: t.pinned,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +57,7 @@ export function loadVirtualTargets(): VirtualTargetItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||||
patchUiPreferences({ virtual: { targets: items } });
|
patchUiPreferences({ virtual: { targets: normalizeTargets(items) } });
|
||||||
notifyVirtualSessionChanged();
|
notifyVirtualSessionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +91,3 @@ export function loadVirtualCardViewMode(): VirtualCardViewMode {
|
|||||||
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||||
patchUiPreferences({ virtual: { cardViewMode: mode } });
|
patchUiPreferences({ virtual: { cardViewMode: mode } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
|
|
||||||
export function resolveTargetCandleType(
|
|
||||||
item: Pick<VirtualTargetItem, 'candleType'>,
|
|
||||||
snapshotTimeframe?: string | null,
|
|
||||||
): string {
|
|
||||||
if (item.candleType) return normalizeStartCandleType(item.candleType);
|
|
||||||
const raw = snapshotTimeframe?.split(',')[0]?.trim();
|
|
||||||
if (raw) return normalizeStartCandleType(raw);
|
|
||||||
return '1m';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,12 +10,34 @@ import { storageGetSync, storageSetSync } from '../storage/index.js';
|
|||||||
/** Flow layout stored with strategy (matches frontend StrategyFlowLayoutStore). */
|
/** Flow layout stored with strategy (matches frontend StrategyFlowLayoutStore). */
|
||||||
export type StrategyFlowLayoutStore = Record<string, unknown>;
|
export type StrategyFlowLayoutStore = Record<string, unknown>;
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
/** 배포 APK·exdev 기본 (HTTPS 443 미개방 — 반드시 http) */
|
||||||
const fromEnv = typeof import.meta !== 'undefined'
|
export const PRODUCTION_API_BASE = 'http://exdev.co.kr/api';
|
||||||
? (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_API_BASE_URL
|
|
||||||
|
function viteEnv(): Record<string, string> | undefined {
|
||||||
|
return typeof import.meta !== 'undefined'
|
||||||
|
? (import.meta as ImportMeta & { env?: Record<string, string> }).env
|
||||||
: undefined;
|
: undefined;
|
||||||
const fromStorage = storageGetSync('gc_api_base_url');
|
}
|
||||||
return fromStorage || fromEnv || 'http://localhost:8080/api';
|
|
||||||
|
/** 저장된 API URL 정규화 — https exdev·localhost 제거 */
|
||||||
|
export function normalizeApiBaseUrl(url: string | null | undefined): string | null {
|
||||||
|
if (!url?.trim()) return null;
|
||||||
|
let u = url.trim().replace(/\/$/, '');
|
||||||
|
if (/localhost|127\.0\.0\.1/i.test(u)) return null;
|
||||||
|
if (/^https:\/\/exdev\.co\.kr/i.test(u)) {
|
||||||
|
u = u.replace(/^https:/i, 'http:');
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiBase(): string {
|
||||||
|
const env = viteEnv();
|
||||||
|
const fromStorage = normalizeApiBaseUrl(storageGetSync('gc_api_base_url'));
|
||||||
|
const fromEnv = normalizeApiBaseUrl(env?.VITE_API_BASE_URL);
|
||||||
|
if (fromStorage) return fromStorage;
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
if (env?.PROD) return PRODUCTION_API_BASE;
|
||||||
|
return 'http://localhost:8080/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 백엔드 REST base */
|
/** 백엔드 REST base */
|
||||||
@@ -82,8 +104,9 @@ function authHeaders(): Record<string, string> {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
const DEFAULT_FETCH_TIMEOUT_MS = 45_000;
|
||||||
const LOGIN_FETCH_TIMEOUT_MS = 60_000;
|
/** exdev 첫 로그인 응답이 60초 이상 걸리는 경우 있음 */
|
||||||
|
const LOGIN_FETCH_TIMEOUT_MS = 180_000;
|
||||||
|
|
||||||
function wrapNetworkError(e: unknown): Error {
|
function wrapNetworkError(e: unknown): Error {
|
||||||
if (e instanceof TypeError || (e instanceof Error && /failed to fetch/i.test(e.message))) {
|
if (e instanceof TypeError || (e instanceof Error && /failed to fetch/i.test(e.message))) {
|
||||||
|
|||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# app/google-services.json → app/android/app/ (Gradle google-services 플러그인)
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
SRC="$ROOT/app/google-services.json"
|
||||||
|
DEST="$ROOT/app/android/app/google-services.json"
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC" ]]; then
|
||||||
|
echo "[FCM] google-services.json 없음: $SRC"
|
||||||
|
echo " Firebase Console → com.goldenchart.app → json 다운로드 후 app/ 에 두세요."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '"package_name": "com.goldenchart.app"' "$SRC" 2>/dev/null; then
|
||||||
|
echo "[FCM] $SRC 에 com.goldenchart.app 패키지가 없습니다."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$DEST")"
|
||||||
|
cp "$SRC" "$DEST"
|
||||||
|
echo "[FCM] ✓ $DEST"
|
||||||
@@ -24,8 +24,9 @@ if [[ -f "$DEST" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${1:-}" && -f "$1" ]]; then
|
if [[ -n "${1:-}" && -f "$1" ]]; then
|
||||||
|
cp "$1" "$ROOT/app/google-services.json"
|
||||||
cp "$1" "$DEST"
|
cp "$1" "$DEST"
|
||||||
echo "✓ 복사: $1 → $DEST"
|
echo "✓ 복사: $1 → app/google-services.json 및 $DEST"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,13 @@ build_apk_locally() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}[빌드] google-services.json → android/app${NC}"
|
||||||
|
"$ROOT/scripts/ensure-android-google-services.sh"
|
||||||
|
|
||||||
|
if [[ ! -f "$ROOT/app/.env" ]] || ! grep -q 'VITE_API_BASE_URL=http' "$ROOT/app/.env" 2>/dev/null; then
|
||||||
|
echo -e "${YELLOW}⚠ app/.env 에 VITE_API_BASE_URL=http://exdev.co.kr/api 확인 (HTTPS 아님)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "${CYAN}[빌드] npm run cap:sync${NC}"
|
echo -e "${CYAN}[빌드] npm run cap:sync${NC}"
|
||||||
npm run cap:sync
|
npm run cap:sync
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user