pc 앱 배포,다운로드 기능

This commit is contained in:
Macbook
2026-06-14 10:33:53 +09:00
parent 92f67580e3
commit 407e746822
14 changed files with 640 additions and 86 deletions
+24
View File
@@ -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);
+4 -1
View File
@@ -218,7 +218,10 @@ function AppMainContent({
/>
{appDownloadOpen && (
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
<AppDownloadModal
onClose={() => setAppDownloadOpen(false)}
canBuildDesktop={authUser?.role === 'ADMIN'}
/>
)}
{menuPage === 'dashboard' && (
+129 -33
View File
@@ -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;
+43 -6
View File
@@ -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%;
}
+24
View File
@@ -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 {