pc 앱 배포,다운로드 기능
This commit is contained in:
@@ -13190,6 +13190,30 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
padding: 12px 14px; margin-bottom: 16px;
|
||||
background: var(--bg3); border-radius: 12px; border: 1px solid var(--border);
|
||||
}
|
||||
.app-download-hero--with-action {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
.app-download-hero-main {
|
||||
display: flex; align-items: center; gap: 14px; min-width: 0;
|
||||
}
|
||||
.app-download-build-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid color-mix(in srgb, #2196f3 55%, var(--border));
|
||||
background: linear-gradient(135deg, color-mix(in srgb, #2196f3 18%, var(--bg2)), var(--bg2));
|
||||
color: var(--text); font-size: 12px; font-weight: 700; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.app-download-build-btn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.app-download-build-btn:disabled, .app-download-build-btn.busy { opacity: 0.65; cursor: wait; }
|
||||
.app-download-build-btn.busy svg { animation: tmb-update-spin 0.9s linear infinite; }
|
||||
.app-download-build-status {
|
||||
margin: -8px 0 12px; font-size: 12px; color: var(--text2); line-height: 1.45;
|
||||
}
|
||||
.app-download-build-status--active { color: #5eb5ff; }
|
||||
.app-download-build-note, .app-download-build-wait {
|
||||
margin: 0 0 12px; font-size: 12px; line-height: 1.5;
|
||||
}
|
||||
.app-download-app-icon {
|
||||
width: 52px; height: 52px; border-radius: 14px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #2196f3);
|
||||
|
||||
@@ -218,7 +218,10 @@ function AppMainContent({
|
||||
/>
|
||||
|
||||
{appDownloadOpen && (
|
||||
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
|
||||
<AppDownloadModal
|
||||
onClose={() => setAppDownloadOpen(false)}
|
||||
canBuildDesktop={authUser?.role === 'ADMIN'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{menuPage === 'dashboard' && (
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe)
|
||||
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe + Jenkins 빌드)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import {
|
||||
loadDesktopAppBuildStatus,
|
||||
loadDesktopAppReleaseInfo,
|
||||
loadMobileAppReleaseInfo,
|
||||
triggerDesktopAppBuild,
|
||||
type DesktopAppBuildStatusDto,
|
||||
type DesktopAppPlatformReleaseDto,
|
||||
type DesktopAppReleaseInfoDto,
|
||||
type MobileAppReleaseInfoDto,
|
||||
@@ -14,6 +17,8 @@ import {
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
/** ADMIN — Jenkins 데스크톱 빌드 트리거 */
|
||||
canBuildDesktop?: boolean;
|
||||
}
|
||||
|
||||
type TabId = 'mobile' | 'desktop';
|
||||
@@ -39,6 +44,16 @@ function triggerDownload(url: string, fileName: string) {
|
||||
a.remove();
|
||||
}
|
||||
|
||||
const IcDesktopBuild = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 3v4" />
|
||||
<path d="M8 7h8" />
|
||||
<rect x="5" y="7" width="14" height="12" rx="2" />
|
||||
<path d="M9 13h6" />
|
||||
<path d="M9 17h4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MobileTab: React.FC = () => {
|
||||
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -184,72 +199,153 @@ const PlatformCard: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopTab: React.FC = () => {
|
||||
const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) => {
|
||||
const [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null);
|
||||
const [buildStatus, setBuildStatus] = useState<DesktopAppBuildStatusDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buildBusy, setBuildBusy] = useState(false);
|
||||
const [buildHint, setBuildHint] = useState<string | null>(null);
|
||||
|
||||
const reloadRelease = useCallback(async () => {
|
||||
const data = await loadDesktopAppReleaseInfo();
|
||||
setInfo(data);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const reloadBuildStatus = useCallback(async () => {
|
||||
const st = await loadDesktopAppBuildStatus();
|
||||
setBuildStatus(st);
|
||||
return st;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadDesktopAppReleaseInfo()
|
||||
.then(data => { if (!cancelled) setInfo(data); })
|
||||
void Promise.all([reloadRelease(), reloadBuildStatus()])
|
||||
.catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [reloadRelease, reloadBuildStatus]);
|
||||
|
||||
const isBuilding = Boolean(buildStatus?.building || buildStatus?.status === 'queued');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBuilding) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void reloadBuildStatus().then(st => {
|
||||
if (st && !st.building && st.status !== 'queued') {
|
||||
void reloadRelease();
|
||||
}
|
||||
});
|
||||
}, 8000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isBuilding, reloadBuildStatus, reloadRelease]);
|
||||
|
||||
const handleBuild = useCallback(async () => {
|
||||
if (!canBuildDesktop || buildBusy || isBuilding) return;
|
||||
setBuildBusy(true);
|
||||
setBuildHint('Jenkins 빌드 요청 중…');
|
||||
try {
|
||||
const res = await triggerDesktopAppBuild();
|
||||
setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부'));
|
||||
await reloadBuildStatus();
|
||||
} catch (e) {
|
||||
setBuildHint(e instanceof Error ? e.message : '빌드 요청 실패');
|
||||
} finally {
|
||||
setBuildBusy(false);
|
||||
}
|
||||
}, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]);
|
||||
|
||||
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>;
|
||||
|
||||
const statusMessage = buildHint ?? buildStatus?.message;
|
||||
|
||||
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 className="app-download-hero app-download-hero--with-action">
|
||||
<div className="app-download-hero-main">
|
||||
<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>
|
||||
{canBuildDesktop && buildStatus?.jenkinsEnabled !== false && (
|
||||
<button
|
||||
type="button"
|
||||
className={`app-download-build-btn${buildBusy || isBuilding ? ' busy' : ''}`}
|
||||
onClick={() => { void handleBuild(); }}
|
||||
disabled={buildBusy || isBuilding}
|
||||
title="Git main 최신 소스로 macOS·Windows 데스크톱 앱 Jenkins 빌드"
|
||||
aria-label="데스크톱 앱 빌드"
|
||||
>
|
||||
<IcDesktopBuild />
|
||||
<span>{isBuilding ? '빌드 중…' : '앱 빌드'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasAny && (
|
||||
{statusMessage && (
|
||||
<p className={`app-download-build-status${isBuilding ? ' app-download-build-status--active' : ''}`}>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canBuildDesktop && (
|
||||
<p className="app-download-muted app-download-build-note">
|
||||
데스크톱 앱 빌드는 <strong>관리자</strong> 로그인 후 사용할 수 있습니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!hasAny && !isBuilding && (
|
||||
<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>를 실행하세요.
|
||||
{canBuildDesktop
|
||||
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
|
||||
: '관리자에게 Jenkins 빌드를 요청하세요.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAny && (
|
||||
<>
|
||||
<div className="app-download-pc-list">
|
||||
<PlatformCard platform="mac" release={info?.mac} />
|
||||
<PlatformCard platform="windows" release={info?.windows} />
|
||||
</div>
|
||||
{isBuilding && !hasAny && (
|
||||
<p className="app-download-muted app-download-build-wait">
|
||||
Jenkins에서 macOS dmg · Windows setup.exe를 빌드 중입니다. 완료되면 아래에 다운로드가 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
{(hasAny || isBuilding) && (
|
||||
<div className="app-download-pc-list">
|
||||
<PlatformCard platform="mac" release={info?.mac} />
|
||||
<PlatformCard platform="windows" release={info?.windows} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(hasAny || isBuilding) && (
|
||||
<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>
|
||||
<li>빌드는 Git <code>main</code> 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.</li>
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose, canBuildDesktop = false }) => {
|
||||
const [tab, setTab] = useState<TabId>('mobile');
|
||||
|
||||
return (
|
||||
@@ -283,7 +379,7 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="app-download-body" role="tabpanel">
|
||||
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
|
||||
{tab === 'mobile' ? <MobileTab /> : <DesktopTab canBuildDesktop={canBuildDesktop} />}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ interface Props {
|
||||
onUpdateSlots: (slots: WidgetSlot[]) => void;
|
||||
onUpdateSize: (width: number, height: number) => void;
|
||||
onUpdateGridFr: (rowFr: number[], colFr: number[]) => void;
|
||||
/** Tauri OS 창 — 네이티브 타이틀·리사이즈 사용, 내부 플로팅 크롬 숨김 */
|
||||
nativeShell?: boolean;
|
||||
}
|
||||
|
||||
const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
@@ -32,6 +34,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
onUpdateSlots,
|
||||
onUpdateSize,
|
||||
onUpdateGridFr,
|
||||
nativeShell = false,
|
||||
}) => {
|
||||
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -146,37 +149,49 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
|
||||
const title = `위젯 ${instance.rows}×${instance.cols}`;
|
||||
|
||||
return createPortal(
|
||||
const windowClass = [
|
||||
'fw-window',
|
||||
focused ? 'fw-window--focused' : '',
|
||||
nativeShell ? 'fw-window--native' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const windowStyle = nativeShell
|
||||
? undefined
|
||||
: {
|
||||
...panelStyle,
|
||||
width: instance.width,
|
||||
height: instance.height,
|
||||
zIndex: instance.zIndex,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`fw-window${focused ? ' fw-window--focused' : ''}`}
|
||||
style={{
|
||||
...panelStyle,
|
||||
width: instance.width,
|
||||
height: instance.height,
|
||||
zIndex: instance.zIndex,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={onFocus}
|
||||
className={windowClass}
|
||||
style={windowStyle}
|
||||
onMouseDown={nativeShell ? undefined : onFocus}
|
||||
>
|
||||
<div
|
||||
className="fw-window-head"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="fw-window-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="fw-window-close"
|
||||
title="위젯 창 닫기"
|
||||
aria-label="위젯 창 닫기"
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
{!nativeShell && (
|
||||
<div
|
||||
className="fw-window-head"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<span className="fw-window-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="fw-window-close"
|
||||
title="위젯 창 닫기"
|
||||
aria-label="위젯 창 닫기"
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fw-window-body-wrap">
|
||||
<div
|
||||
@@ -227,20 +242,22 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fw-window-resize-layer">
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (좌하단)"
|
||||
onPointerDown={onResizeSwPointerDown}
|
||||
/>
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--se"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (우하단)"
|
||||
onPointerDown={onResizeSePointerDown}
|
||||
/>
|
||||
</div>
|
||||
{!nativeShell && (
|
||||
<div className="fw-window-resize-layer">
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (좌하단)"
|
||||
onPointerDown={onResizeSwPointerDown}
|
||||
/>
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--se"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (우하단)"
|
||||
onPointerDown={onResizeSePointerDown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WidgetPickerModal
|
||||
@@ -249,9 +266,10 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
onClose={() => setPickSlotId(null)}
|
||||
onSelect={applyWidgetType}
|
||||
/>
|
||||
</>,
|
||||
document.body,
|
||||
</>
|
||||
);
|
||||
|
||||
return nativeShell ? content : createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default FloatingWidgetWindow;
|
||||
|
||||
@@ -595,24 +595,61 @@
|
||||
}
|
||||
|
||||
/* Tauri 네이티브 위젯 창 */
|
||||
/* Tauri 네이티브 위젯 창 — OS 창 전체를 콘텐츠 영역으로 사용 */
|
||||
html.fw-native-widget,
|
||||
html.fw-native-widget body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #1a1b26);
|
||||
color: var(--text, #c0caf5);
|
||||
font-family: var(--font, 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif);
|
||||
}
|
||||
|
||||
html.fw-native-widget #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fw-layer--picker-only {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fw-widget-native-root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg, #1a1b26);
|
||||
}
|
||||
|
||||
.fw-widget-native-root .fw-window {
|
||||
position: static !important;
|
||||
.fw-widget-native-root .fw-window,
|
||||
.fw-widget-native-root .fw-window--native {
|
||||
position: relative !important;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.fw-window--native .fw-window-body-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fw-window--native .fw-window-body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -665,6 +665,30 @@ export async function loadDesktopAppReleaseInfo(): Promise<DesktopAppReleaseInfo
|
||||
return request<DesktopAppReleaseInfoDto>('/desktop-app/info');
|
||||
}
|
||||
|
||||
export interface DesktopAppBuildStatusDto {
|
||||
jenkinsEnabled: boolean;
|
||||
building: boolean;
|
||||
status: string;
|
||||
buildNumber?: number;
|
||||
message?: string;
|
||||
buildUrl?: string;
|
||||
startedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface DesktopAppBuildTriggerDto {
|
||||
accepted: boolean;
|
||||
buildNumber?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function loadDesktopAppBuildStatus(): Promise<DesktopAppBuildStatusDto | null> {
|
||||
return request<DesktopAppBuildStatusDto>('/desktop-app/build/status');
|
||||
}
|
||||
|
||||
export async function triggerDesktopAppBuild(): Promise<DesktopAppBuildTriggerDto> {
|
||||
return requestOrThrow<DesktopAppBuildTriggerDto>('/desktop-app/build', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 모의투자 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaperPositionDto {
|
||||
|
||||
Reference in New Issue
Block a user