This commit is contained in:
2025-10-19 21:48:39 +09:00
commit b983b79b39
9 changed files with 1757 additions and 0 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>원격 제어 관리자</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
// frontend/src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import RemoteControl from './remote.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RemoteControl />
</React.StrictMode>
);

237
frontend/src/remote.jsx Normal file
View File

@@ -0,0 +1,237 @@
// frontend/src/RemoteControl.jsx
import { useState, useRef, useEffect } from 'react';
import io from 'socket.io-client';
const SIGNALING_SERVER = 'http://localhost:3001'; // 실제 서버 주소로 변경
export default function RemoteControl() {
const [employeeId, setEmployeeId] = useState('');
const [status, setStatus] = useState('disconnected'); // 'disconnected' | 'connecting' | 'connected'
const [displays, setDisplays] = useState([]);
const [selectedDisplay, setSelectedDisplay] = useState(null);
const [isControlling, setIsControlling] = useState(false);
const videoRef = useRef(null);
const socketRef = useRef(null);
const peerConnectionRef = useRef(null);
// 🔌 소켓 연결
useEffect(() => {
const socket = io(SIGNALING_SERVER, {
reconnection: true,
reconnectionAttempts: Infinity
});
socketRef.current = socket;
socket.on('availableDisplays', ({ displays }) => {
setDisplays(displays);
if (displays.length > 0 && !selectedDisplay) {
setSelectedDisplay(displays[0].id);
}
});
socket.on('webrtcSignal', async ({ data }) => {
if (!peerConnectionRef.current) return;
try {
if (data.type === 'answer') {
await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(data));
setStatus('connected');
setIsControlling(true);
} else if (data.type === 'icecandidate' && data.candidate) {
await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(data.candidate));
}
} catch (err) {
console.error('WebRTC 처리 오류:', err);
}
});
return () => {
socket.disconnect();
};
}, [selectedDisplay]);
// ▶️ 연결 시작
const startControl = async () => {
if (!employeeId) return;
setStatus('connecting');
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
peerConnectionRef.current = pc;
pc.ontrack = (event) => {
if (videoRef.current) {
videoRef.current.srcObject = event.streams[0];
}
};
pc.onicecandidate = (event) => {
if (event.candidate && socketRef.current) {
socketRef.current.emit('webrtcSignal', {
targetId: employeeId,
type: 'icecandidate',
candidate: event.candidate
});
}
};
try {
const offer = await pc.createOffer({ offerToReceiveVideo: true });
await pc.setLocalDescription(offer);
socketRef.current.emit('requestControl', {
targetId: employeeId,
offer: offer,
displayId: selectedDisplay
});
} catch (err) {
console.error('연결 실패:', err);
setStatus('disconnected');
}
};
// 🖱️ 입력 이벤트 전달
const handleMouseEvent = (e) => {
if (!isControlling || !videoRef.current) return;
const video = videoRef.current;
const rect = video.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 비율 기반 좌표 → 실제 해상도로 변환 (직원 측에서 display 크기 알면 정확히 변환 가능)
// 여기선 예시로 1920x1080 기준
const scaleX = 1920 / video.videoWidth;
const scaleY = 1080 / video.videoHeight;
socketRef.current?.emit('inputEvent', {
targetId: employeeId,
type: 'mouse',
action: e.type === 'click' ? 'click' : 'move',
x: x * scaleX,
y: y * scaleY,
button: e.button
});
};
const handleKeyDown = (e) => {
if (!isControlling) return;
socketRef.current?.emit('inputEvent', {
targetId: employeeId,
type: 'keyboard',
key: e.key,
keyCode: e.keyCode,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
altKey: e.altKey
});
};
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '1200px', margin: '0 auto' }}>
<h2 style={{ fontSize: '24px' }}>🖥 원격 제어 관리자</h2>
{status === 'disconnected' && (
<div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '18px', marginRight: '10px' }}>직원 번호:</label>
<input
type="text"
value={employeeId}
onChange={(e) => setEmployeeId(e.target.value)}
placeholder="예: EMP123"
style={{
fontSize: '18px',
padding: '10px',
width: '200px',
border: '1px solid #ccc',
borderRadius: '4px'
}}
/>
</div>
{displays.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '18px', marginRight: '10px' }}>모니터 선택:</label>
<select
value={selectedDisplay || ''}
onChange={(e) => setSelectedDisplay(e.target.value)}
style={{ fontSize: '16px', padding: '8px' }}
>
{displays.map((d) => (
<option key={d.id} value={d.id}>
{d.name} ({d.width}×{d.height})
</option>
))}
</select>
</div>
)}
<button
onClick={startControl}
disabled={!employeeId || status === 'connecting'}
style={{
fontSize: '20px',
padding: '12px 24px',
backgroundColor: '#2196F3',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: employeeId ? 'pointer' : 'not-allowed'
}}
>
원격 연결 요청
</button>
</div>
)}
{status === 'connecting' && (
<p style={{ fontSize: '18px', color: '#e65100' }}>직원의 승인을 기다리는 ...</p>
)}
{status === 'connected' && (
<p style={{ fontSize: '18px', color: 'green' }}> 연결 성공! 화면을 제어할 있습니다.</p>
)}
{isControlling && (
<div
tabIndex={0}
style={{
width: '100%',
height: '600px',
border: '2px solid #444',
position: 'relative',
outline: 'none',
backgroundColor: '#000'
}}
onMouseMove={handleMouseEvent}
onClick={handleMouseEvent}
onKeyDown={handleKeyDown}
>
<video
ref={videoRef}
autoPlay
playsInline
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
<div
style={{
position: 'absolute',
top: '12px',
left: '12px',
background: 'rgba(0,0,0,0.7)',
color: 'white',
padding: '6px 12px',
borderRadius: '4px',
fontSize: '16px'
}}
>
직원: {employeeId} | 모니터: {selectedDisplay}
</div>
</div>
)}
</div>
);
}