pc 프로그램 다운로드 기능 적용
This commit is contained in:
@@ -1,28 +1,45 @@
|
||||
/**
|
||||
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
|
||||
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
|
||||
import {
|
||||
loadDesktopAppReleaseInfo,
|
||||
loadMobileAppReleaseInfo,
|
||||
type DesktopAppPlatformReleaseDto,
|
||||
type DesktopAppReleaseInfoDto,
|
||||
type MobileAppReleaseInfoDto,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabId = 'mobile' | 'desktop';
|
||||
|
||||
function formatBytes(n?: number): string {
|
||||
if (n == null || !Number.isFinite(n)) return '—';
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
|
||||
function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
|
||||
if (!info?.available) return '';
|
||||
return info.downloadUrl?.trim() ?? '';
|
||||
}
|
||||
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
function triggerDownload(url: string, fileName: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
const MobileTab: React.FC = () => {
|
||||
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
@@ -33,15 +50,9 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadMobileAppReleaseInfo()
|
||||
.then(data => {
|
||||
if (!cancelled) setInfo(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
.then(data => { if (!cancelled) setInfo(data); })
|
||||
.catch(() => { if (!cancelled) setError('앱 정보를 불러오지 못했습니다.'); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
@@ -55,94 +66,224 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
color: { dark: '#111827', light: '#ffffff' },
|
||||
}).then(url => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setQrDataUrl(null);
|
||||
});
|
||||
}).then(url => { if (!cancelled) setQrDataUrl(url); })
|
||||
.catch(() => { if (!cancelled) setQrDataUrl(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [downloadUrl]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = info?.fileName ?? 'goldenchart-android.apk';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
if (loading) return <p className="app-download-muted">앱 정보 불러오는 중…</p>;
|
||||
if (error) return <p className="app-download-error">{error}</p>;
|
||||
if (!info?.available) {
|
||||
return (
|
||||
<div className="app-download-empty">
|
||||
<div className="app-download-empty-icon" aria-hidden>📱</div>
|
||||
<h3>설치 파일 준비 중</h3>
|
||||
<p>
|
||||
Android APK가 아직 서버에 업로드되지 않았습니다.
|
||||
<br />
|
||||
<code>data/mobile-releases/goldenchart-android.apk</code> 경로에 빌드 파일을 배치하세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart Mobile</h3>
|
||||
<p className="app-download-meta">
|
||||
{info.version && <span>v{info.version}</span>}
|
||||
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
||||
{info.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-download-qr-block">
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
|
||||
) : (
|
||||
<div className="app-download-qr app-download-qr--placeholder">QR 생성 중…</div>
|
||||
)}
|
||||
<div className="app-download-qr-help">
|
||||
<strong>휴대폰 카메라로 QR 스캔</strong>
|
||||
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
||||
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="app-download-btn-primary"
|
||||
onClick={() => triggerDownload(downloadUrl, info.fileName ?? 'goldenchart-android.apk')}
|
||||
>
|
||||
Android APK 다운로드
|
||||
</button>
|
||||
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법 (Android)</summary>
|
||||
<ol>
|
||||
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
||||
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
||||
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<p className="app-download-ios-note">
|
||||
iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformCard: React.FC<{
|
||||
platform: 'mac' | 'windows';
|
||||
release?: DesktopAppPlatformReleaseDto;
|
||||
}> = ({ platform, release }) => {
|
||||
const isMac = platform === 'mac';
|
||||
const label = isMac ? 'macOS (Apple Silicon · Intel)' : 'Windows (64-bit)';
|
||||
const icon = isMac ? '🍎' : '🪟';
|
||||
const ext = isMac ? '.dmg' : '.exe';
|
||||
|
||||
if (!release?.available || !release.downloadUrl) {
|
||||
return (
|
||||
<div className="app-download-pc-card app-download-pc-card--empty">
|
||||
<div className="app-download-pc-icon" aria-hidden>{icon}</div>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<p className="app-download-muted">설치 파일 준비 중 ({ext})</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-download-pc-card">
|
||||
<div className="app-download-pc-icon" aria-hidden>{icon}</div>
|
||||
<div className="app-download-pc-body">
|
||||
<strong>{label}</strong>
|
||||
<p className="app-download-meta">
|
||||
{release.version && <span>v{release.version}</span>}
|
||||
{release.sizeBytes != null && <span>{formatBytes(release.sizeBytes)}</span>}
|
||||
{release.updatedAt && <span>{release.updatedAt}</span>}
|
||||
</p>
|
||||
<p className="app-download-muted app-download-url" title={release.downloadUrl}>
|
||||
{release.fileName}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="app-download-pc-btn"
|
||||
onClick={() => triggerDownload(release.downloadUrl!, release.fileName ?? `GoldenChart${ext}`)}
|
||||
>
|
||||
다운로드
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopTab: React.FC = () => {
|
||||
const [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadDesktopAppReleaseInfo()
|
||||
.then(data => { if (!cancelled) setInfo(data); })
|
||||
.catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const hasAny = info?.mac?.available || info?.windows?.available;
|
||||
|
||||
if (loading) return <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
|
||||
if (error) return <p className="app-download-error">{error}</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon app-download-app-icon--desktop" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart Desktop</h3>
|
||||
<p className="app-download-meta">
|
||||
{info?.version && <span>v{info.version}</span>}
|
||||
{info?.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasAny && (
|
||||
<div className="app-download-empty">
|
||||
<div className="app-download-empty-icon" aria-hidden>💻</div>
|
||||
<h3>설치 파일 준비 중</h3>
|
||||
<p>
|
||||
macOS(.dmg) 또는 Windows(.exe) 설치 파일이 아직 서버에 없습니다.
|
||||
<br />
|
||||
Jenkins 빌드 후 <code>scripts/publish-desktop-update.sh</code>를 실행하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAny && (
|
||||
<>
|
||||
<div className="app-download-pc-list">
|
||||
<PlatformCard platform="mac" release={info?.mac} />
|
||||
<PlatformCard platform="windows" release={info?.windows} />
|
||||
</div>
|
||||
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법</summary>
|
||||
<ol>
|
||||
<li><strong>macOS</strong>: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.</li>
|
||||
<li><strong>Windows</strong>: setup.exe를 실행하고 안내에 따라 설치합니다.</li>
|
||||
<li>설치된 PC 앱은 메뉴 우측 상단 <strong>업데이트</strong> 버튼으로 자동 업데이트할 수 있습니다.</li>
|
||||
</ol>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
const [tab, setTab] = useState<TabId>('mobile');
|
||||
|
||||
return (
|
||||
<DraggableModalFrame
|
||||
onClose={onClose}
|
||||
title="Mobile App"
|
||||
titleKo="GoldenChart 앱 설치"
|
||||
badge="Android"
|
||||
width={440}
|
||||
title="Apps"
|
||||
titleKo="GoldenChart 앱·PC 프로그램"
|
||||
badge={tab === 'mobile' ? 'Mobile' : 'Desktop'}
|
||||
width={480}
|
||||
dialogClassName="sp-modal app-download-modal"
|
||||
>
|
||||
<div className="app-download-body">
|
||||
{loading && <p className="app-download-muted">앱 정보 불러오는 중…</p>}
|
||||
{!loading && error && <p className="app-download-error">{error}</p>}
|
||||
{!loading && !error && !info?.available && (
|
||||
<div className="app-download-empty">
|
||||
<div className="app-download-empty-icon" aria-hidden>📱</div>
|
||||
<h3>설치 파일 준비 중</h3>
|
||||
<p>
|
||||
Android APK가 아직 서버에 업로드되지 않았습니다.
|
||||
<br />
|
||||
<code>data/mobile-releases/goldenchart-android.apk</code> 경로에 빌드 파일을 배치하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && info?.available && (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart</h3>
|
||||
<p className="app-download-meta">
|
||||
{info.version && <span>v{info.version}</span>}
|
||||
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
||||
{info.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-download-tabs" role="tablist" aria-label="다운로드 종류">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'mobile'}
|
||||
className={`app-download-tab${tab === 'mobile' ? ' active' : ''}`}
|
||||
onClick={() => setTab('mobile')}
|
||||
>
|
||||
모바일
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'desktop'}
|
||||
className={`app-download-tab${tab === 'desktop' ? ' active' : ''}`}
|
||||
onClick={() => setTab('desktop')}
|
||||
>
|
||||
PC 프로그램
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-download-qr-block">
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
|
||||
) : (
|
||||
<div className="app-download-qr app-download-qr--placeholder">QR 생성 중…</div>
|
||||
)}
|
||||
<div className="app-download-qr-help">
|
||||
<strong>휴대폰 카메라로 QR 스캔</strong>
|
||||
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
||||
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
|
||||
Android APK 다운로드
|
||||
</button>
|
||||
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법 (Android)</summary>
|
||||
<ol>
|
||||
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
||||
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
||||
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
||||
<li>설치 후 앱에서 API 서버 URL을 설정하세요 (설정 › 네트워크).</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<p className="app-download-ios-note">
|
||||
iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="app-download-body" role="tabpanel">
|
||||
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
* 레이아웃:
|
||||
* [로고] | [대시보드] [실시간차트] [전략편집기] … | [테마토글] [로그인]
|
||||
*/
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import type { AuthSession } from '../utils/auth';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { canAccessMenu } from '../utils/permissions';
|
||||
import { isDesktop } from '../utils/platform';
|
||||
import { checkDesktopUpdates } from '../utils/desktopBridge';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard';
|
||||
|
||||
@@ -105,6 +107,13 @@ const IcAppDownload = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcDesktopUpdate = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/>
|
||||
<polyline points="13.5,2.5 13.5,5.5 10.5,5.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcNotify = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
|
||||
@@ -257,6 +266,24 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
menuPermissions,
|
||||
}: TopMenuBarProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
||||
const [desktopUpdateBusy, setDesktopUpdateBusy] = useState(false);
|
||||
const [desktopUpdateHint, setDesktopUpdateHint] = useState('');
|
||||
const desktopClient = isDesktop();
|
||||
|
||||
const handleDesktopUpdate = useCallback(async () => {
|
||||
if (desktopUpdateBusy) return;
|
||||
setDesktopUpdateBusy(true);
|
||||
setDesktopUpdateHint('업데이트 확인 중…');
|
||||
try {
|
||||
const res = await checkDesktopUpdates();
|
||||
setDesktopUpdateHint(res.message ?? (res.available ? `v${res.version ?? ''} 설치` : '최신 버전'));
|
||||
} catch {
|
||||
setDesktopUpdateHint('업데이트 확인 실패');
|
||||
} finally {
|
||||
setDesktopUpdateBusy(false);
|
||||
}
|
||||
}, [desktopUpdateBusy]);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', sync);
|
||||
@@ -326,19 +353,28 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onOpenAppDownload && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-app-download-btn"
|
||||
onClick={onOpenAppDownload}
|
||||
title="GoldenChart 모바일 앱 다운로드"
|
||||
aria-label="앱 다운로드"
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{desktopClient ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`tmb-app-download-btn tmb-desktop-update-btn${desktopUpdateBusy ? ' busy' : ''}`}
|
||||
onClick={() => { void handleDesktopUpdate(); }}
|
||||
disabled={desktopUpdateBusy}
|
||||
title={desktopUpdateHint || 'PC 프로그램 업데이트'}
|
||||
aria-label="PC 프로그램 업데이트"
|
||||
>
|
||||
<IcDesktopUpdate />
|
||||
</button>
|
||||
) : onOpenAppDownload ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-app-download-btn"
|
||||
onClick={onOpenAppDownload}
|
||||
title="GoldenChart 앱·PC 프로그램 다운로드"
|
||||
aria-label="앱·PC 프로그램 다운로드"
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{onFullscreen && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user