ChatSystem init...1
This commit is contained in:
commit
bad40540bd
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASS=
|
||||
DB_NAME=chat_db
|
||||
PORT=3000
|
||||
SESSION_SECRET=your_secret_key
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
#codeigniter4
|
||||
.env
|
||||
package-lock.json
|
||||
node_modules/
|
||||
public/vendor/
|
||||
uploads/
|
||||
27
package.json
Normal file
27
package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "chatsystem",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://gitlab.idcjp.jp:3000/idcjp/ChatSystem.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"fs-extra": "^11.3.3",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.16.0",
|
||||
"socket.io": "^4.8.3"
|
||||
}
|
||||
}
|
||||
382
public/chat.js
Normal file
382
public/chat.js
Normal file
@ -0,0 +1,382 @@
|
||||
const socket = io();
|
||||
|
||||
// UI Elements
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
const messageInput = document.getElementById('message-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const authToggleBtn = document.getElementById('auth-toggle-btn');
|
||||
const loginArea = document.getElementById('login-area');
|
||||
const chatInputArea = document.getElementById('chat-input-area');
|
||||
const loginSubmitBtn = document.getElementById('login-submit-btn');
|
||||
const loginCancelBtn = document.getElementById('login-cancel-btn');
|
||||
const imageBtn = document.getElementById('image-btn');
|
||||
const imageInput = document.getElementById('image-input');
|
||||
const replyInfo = document.getElementById('reply-info');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const roomListContainer = document.getElementById('room-list');
|
||||
const roomSearchInput = document.getElementById('room-search');
|
||||
const headerAvatar = document.getElementById('header-avatar');
|
||||
const targetNameLabel = document.getElementById('target-name');
|
||||
const targetStatusLabel = document.getElementById('target-status');
|
||||
const exitReplyBtn = document.getElementById('exit-reply-btn');
|
||||
const signupShowBtn = document.getElementById('signup-show-btn');
|
||||
|
||||
// Unique Anonymous ID generation/recovery
|
||||
function getAnonymousId() {
|
||||
let id = localStorage.getItem('chat_anon_id');
|
||||
if (!id) {
|
||||
id = 'anon_' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('chat_anon_id', id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const isAdminPath = window.location.pathname.includes('/admin');
|
||||
|
||||
// Load User from LocalStorage or create Anonymous
|
||||
let savedUser = localStorage.getItem('chat_user');
|
||||
let currentUser;
|
||||
|
||||
// CI4 Session Bridge: CI4 뷰에서 넘겨준 세션 정보가 있는지 확인
|
||||
// CI4 PHP 예시: window.chatConfig = { isLog-in: <?= session()->get('ISLGIN') ?>, auth: <?= json_encode(session()->get('AUTH')) ?> };
|
||||
if (window.chatConfig && window.chatConfig.ISLGIN) {
|
||||
const auth = window.chatConfig.AUTH;
|
||||
currentUser = {
|
||||
id: auth.id || auth.uid,
|
||||
username: auth.name || auth.id,
|
||||
role: auth.role || 'user'
|
||||
};
|
||||
// 세션 정보가 있으면 로컬 스토리지도 동기화 (선택 사항)
|
||||
localStorage.setItem('chat_user', JSON.stringify(currentUser));
|
||||
} else if (savedUser) {
|
||||
currentUser = JSON.parse(savedUser);
|
||||
} else {
|
||||
currentUser = { id: getAnonymousId(), username: '익명', role: 'guest' };
|
||||
}
|
||||
|
||||
if (currentUser && currentUser.role !== 'guest') {
|
||||
if (authToggleBtn) authToggleBtn.textContent = '로그아웃 (' + currentUser.username + ')';
|
||||
}
|
||||
|
||||
let currentRoom = (isAdminPath && isConsultantRole(currentUser.role))
|
||||
? 'consultants_group'
|
||||
: 'room_' + currentUser.id;
|
||||
let currentConsultant = null;
|
||||
|
||||
// Initialize
|
||||
if (isAdminPath) {
|
||||
const headerTitle = document.querySelector('#chat-header span');
|
||||
if (headerTitle) headerTitle.innerText = 'IDC 상담원 관리 시스템';
|
||||
|
||||
// 상담원 채널 접속 시 로그인 영역 자동 표시 및 사이드바 노출 준비
|
||||
if (currentUser.role === 'guest') {
|
||||
loginArea.style.display = 'flex';
|
||||
} else if (isConsultantRole(currentUser.role)) {
|
||||
if (sidebar) sidebar.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
// 일반 채팅 경로(/chat)
|
||||
authToggleBtn.innerText = '상담원 로그인';
|
||||
if (sidebar) sidebar.style.display = 'none';
|
||||
if (replyInfo) replyInfo.style.display = 'none';
|
||||
|
||||
// 일반 사용자용 화면은 너비를 컴팩트하게 조정 (기존 1000px의 70% 수준인 700px)
|
||||
const chatWidget = document.getElementById('chat-widget');
|
||||
if (chatWidget) chatWidget.style.width = '700px';
|
||||
}
|
||||
|
||||
socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role });
|
||||
|
||||
// 룸 목록 업데이트 수신 (상담원 전용)
|
||||
socket.on('update_room_list', (roomList) => {
|
||||
if (!roomListContainer) return;
|
||||
|
||||
allRoomsData = roomList; // 검색 필터링을 위해 저장
|
||||
renderRoomList(allRoomsData);
|
||||
});
|
||||
|
||||
let allRoomsData = [];
|
||||
|
||||
function getAvatarColor(name) {
|
||||
const colors = ['#0078d4', '#107c10', '#d13438', '#008575', '#8764b8', '#004e8c'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
function renderRoomList(rooms) {
|
||||
// 기본 모니터링 룸 추가
|
||||
let listHtml = `
|
||||
<div class="room-item ${currentRoom === 'consultants_group' ? 'active' : ''}" onclick="exitReplyMode()">
|
||||
<div class="avatar" style="background: #605e5c;">📢</div>
|
||||
<div class="room-info">
|
||||
<span class="name">전체 모니터링</span>
|
||||
<span class="preview">모든 실시간 대화 감시</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
rooms.forEach(roomInfo => {
|
||||
const isActive = currentRoom === roomInfo.room;
|
||||
const initial = roomInfo.username.charAt(0);
|
||||
const avatarColor = getAvatarColor(roomInfo.username);
|
||||
|
||||
listHtml += `
|
||||
<div class="room-item ${isActive ? 'active' : ''}" onclick="joinUserRoom('${roomInfo.room}', '${roomInfo.username}')">
|
||||
<div class="avatar" style="background: ${avatarColor};">${initial}</div>
|
||||
<div class="room-info">
|
||||
<span class="name">${roomInfo.username}</span>
|
||||
<span class="preview">최근 메시지 확인 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
roomListContainer.innerHTML = listHtml;
|
||||
}
|
||||
|
||||
// 룸 검색 필터링 제거 (사용자 요청)
|
||||
// if (roomSearchInput) { ... }
|
||||
|
||||
// Send Message
|
||||
async function sendMessage(text = null, imageUrl = null) {
|
||||
const message = text || messageInput.value.trim();
|
||||
if (message || imageUrl) {
|
||||
const data = {
|
||||
room: currentRoom,
|
||||
sender: currentUser.username,
|
||||
user_code: currentUser.id,
|
||||
consultant_code: currentConsultant,
|
||||
message: imageUrl || message,
|
||||
msg_type: imageUrl ? 'image' : 'text',
|
||||
role: currentUser.role, // Add role to metadata
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
};
|
||||
socket.emit('send_message', data);
|
||||
if (!imageUrl) messageInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', () => sendMessage());
|
||||
messageInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') sendMessage();
|
||||
});
|
||||
|
||||
// Image Upload Handling
|
||||
imageBtn.addEventListener('click', () => imageInput.click());
|
||||
imageInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) await uploadAndSendImage(file);
|
||||
imageInput.value = '';
|
||||
});
|
||||
|
||||
// Clipboard Paste Handling (Screen Capture)
|
||||
messageInput.addEventListener('paste', async (e) => {
|
||||
const clipboardData = e.clipboardData || window.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
const items = clipboardData.items;
|
||||
let imageFound = false;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
imageFound = true;
|
||||
console.log('이미지 붙여넣기 감지됨:', file.name);
|
||||
await uploadAndSendImage(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 이미지를 전송했다면 텍스트박스에 텍스트가 중복으로 들어가는 것을 방지하고 싶을 때 사용 (선택 사항)
|
||||
// if (imageFound) e.preventDefault();
|
||||
});
|
||||
|
||||
async function uploadAndSendImage(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
console.log('이미지 업로드 시도 중:', file.name);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`서버 응답 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
console.log('이미지 업로드 성공:', result.imageUrl);
|
||||
await sendMessage(null, result.imageUrl);
|
||||
} else {
|
||||
alert('이미지 업로드 실패: ' + result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('이미지 업로드 프로세스 오류:', err);
|
||||
alert('이미지 전송 중 오류가 발생했습니다. 서버 로그를 확인하거나 다시 시도해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: 상담원 역할 확인
|
||||
function isConsultantRole(role) {
|
||||
if (!role) return false;
|
||||
const consultantKeywords = ['manage', 'manager', 'cloudflare', 'security', 'director', 'master', 'admin', 'consultant'];
|
||||
const roles = String(role).toLowerCase();
|
||||
return consultantKeywords.some(keyword => roles.includes(keyword));
|
||||
}
|
||||
|
||||
// Receive Message
|
||||
socket.on('receive_message', (data) => {
|
||||
if (!chatMessages) return;
|
||||
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message');
|
||||
|
||||
// Align messages: Consultant right, Guest left (Skype style is often left-dominant, but we follow chat standards)
|
||||
const isConsultant = isConsultantRole(data.role);
|
||||
const isMe = (data.user_code === currentUser.id);
|
||||
|
||||
msgDiv.classList.add('message');
|
||||
msgDiv.classList.add(isMe ? 'right' : 'left');
|
||||
|
||||
let contentHtml = '';
|
||||
if (data.msg_type === 'image') {
|
||||
contentHtml += `<img src="${data.message}" style="max-width:100%; border-radius:8px; cursor:pointer;" onclick="window.open(this.src)">`;
|
||||
} else {
|
||||
contentHtml += `<span>${data.message}</span>`;
|
||||
}
|
||||
|
||||
msgDiv.innerHTML = contentHtml;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
|
||||
// 만약 상담원이고 전체 모니터링 모드라면 룸 목록의 미리보기 텍스트 업데이트
|
||||
if (isAdminPath && isConsultantRole(currentUser.role) && currentRoom === 'consultants_group') {
|
||||
const roomItem = document.querySelector(`.room-item[onclick*="${data.room}"] .preview`);
|
||||
if (roomItem) {
|
||||
roomItem.innerText = data.msg_type === 'image' ? '(이미지 메시지)' : data.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 상담원이 특정 유저의 대화방에 참여하는 함수 (답장 기능)
|
||||
function joinUserRoom(roomName, userName) {
|
||||
if (currentRoom === roomName) return; // 이미 해당 방이면 무시
|
||||
|
||||
// 기존 대화 내용 비우기
|
||||
chatMessages.innerHTML = '';
|
||||
|
||||
currentRoom = roomName;
|
||||
socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role });
|
||||
|
||||
// UI 업데이트
|
||||
updateSidebarActive();
|
||||
if (targetNameLabel) targetNameLabel.innerText = userName;
|
||||
if (targetStatusLabel) targetStatusLabel.innerText = '상담 중';
|
||||
if (headerAvatar) {
|
||||
headerAvatar.innerText = userName.charAt(0);
|
||||
headerAvatar.style.background = getAvatarColor(userName);
|
||||
}
|
||||
if (exitReplyBtn) exitReplyBtn.style.display = 'block';
|
||||
|
||||
const sysMsg = document.createElement('div');
|
||||
sysMsg.style.cssText = "text-align:center; margin:20px 0; font-size:12px; color:var(--text-sub); width:100%;";
|
||||
sysMsg.innerHTML = `--- <b>${userName}</b> 님과의 대화가 시작되었습니다 ---`;
|
||||
chatMessages.appendChild(sysMsg);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
function updateSidebarActive() {
|
||||
const items = document.querySelectorAll('.room-item');
|
||||
items.forEach(item => {
|
||||
// onclick 속성에서 룸 분석 (간결한 구현을 위해 속성 매칭)
|
||||
if (item.getAttribute('onclick').includes(currentRoom)) {
|
||||
item.classList.add('active');
|
||||
} else {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 답장 모드 종료 (전체 모니터링으로 복귀)
|
||||
function exitReplyMode() {
|
||||
if (currentRoom === 'consultants_group') return;
|
||||
|
||||
chatMessages.innerHTML = '';
|
||||
|
||||
currentRoom = 'consultants_group';
|
||||
socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role });
|
||||
|
||||
updateSidebarActive();
|
||||
if (targetNameLabel) targetNameLabel.innerText = '전체 모니터링';
|
||||
if (targetStatusLabel) targetStatusLabel.innerText = '실시간 대기 중';
|
||||
if (headerAvatar) {
|
||||
headerAvatar.innerText = '📢';
|
||||
headerAvatar.style.background = '#605e5c';
|
||||
}
|
||||
if (exitReplyBtn) exitReplyBtn.style.display = 'none';
|
||||
|
||||
const sysMsg = document.createElement('div');
|
||||
sysMsg.style.cssText = "text-align:center; margin:20px 0; font-size:12px; color:var(--text-sub); width:100%;";
|
||||
sysMsg.innerHTML = `--- 실시간 전체 대화 모니터링 중입니다 ---`;
|
||||
chatMessages.appendChild(sysMsg);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
if (exitReplyBtn) {
|
||||
exitReplyBtn.addEventListener('click', exitReplyMode);
|
||||
}
|
||||
|
||||
// Auth Toggle
|
||||
authToggleBtn.addEventListener('click', () => {
|
||||
if (currentUser.role === 'guest') {
|
||||
loginArea.style.display = loginArea.style.display === 'flex' ? 'none' : 'flex';
|
||||
} else {
|
||||
// 로그아웃 처리: 세션 삭제 후 새로고침
|
||||
localStorage.removeItem('chat_user');
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Login Cancellation
|
||||
loginCancelBtn.addEventListener('click', () => {
|
||||
if (isAdminPath) {
|
||||
location.href = '/chat';
|
||||
} else {
|
||||
loginArea.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Login Submit
|
||||
loginSubmitBtn.addEventListener('click', async () => {
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 로그인 정보 저장 및 새로고침
|
||||
localStorage.setItem('chat_user', JSON.stringify(result.user));
|
||||
alert(`${result.user.username}님 환영합니다!`);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('로그인 실패: ' + result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
alert('로그인 처리 중 오류 발생');
|
||||
}
|
||||
});
|
||||
// Signup Show(Page Redirect)는 HTML의 onclick="location.href='/register'"으로 처리됨
|
||||
360
public/index.html
Normal file
360
public/index.html
Normal file
@ -0,0 +1,360 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IDC 상담 채팅</title>
|
||||
<style>
|
||||
:root {
|
||||
--skype-blue: #0078d4;
|
||||
--skype-bg: #f3f2f1;
|
||||
--sidebar-bg: #ffffff;
|
||||
--text-main: #201f1e;
|
||||
--text-sub: #605e5c;
|
||||
--msg-sent: #0078d4;
|
||||
--msg-received: #f3f2f1;
|
||||
--border-color: #edebe9;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #faf9f8;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#chat-widget {
|
||||
width: 1000px;
|
||||
height: 85vh;
|
||||
background: white;
|
||||
display: flex;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
#sidebar {
|
||||
width: 320px;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#sidebar-header h2 {
|
||||
font-size: 18px;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
background: #f3f2f1;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#room-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.room-item {
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.room-item:hover {
|
||||
background: #f3f2f1;
|
||||
}
|
||||
|
||||
.room-item.active {
|
||||
background: #edebe9;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--skype-blue);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-info .name {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.room-info .preview {
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Main Chat Styles */
|
||||
#main-chat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#chat-header {
|
||||
padding: 15px 25px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: white;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
#chat-header .user-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#chat-header .user-meta .name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#chat-messages {
|
||||
flex: 1;
|
||||
padding: 20px 30px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 70%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message.left {
|
||||
align-self: flex-start;
|
||||
background: var(--msg-received);
|
||||
color: var(--text-main);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message.right {
|
||||
align-self: flex-end;
|
||||
background: var(--msg-sent);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message .sender-id {
|
||||
display: none;
|
||||
/* Skype 스타일은 버블 내 아이디 표시 지양 */
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
#chat-input-area {
|
||||
padding: 15px 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#message-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--skype-blue);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#send-btn {
|
||||
background: var(--skype-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 18px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Modals & Others */
|
||||
.login-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
width: 320px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-content input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#auth-toggle-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--skype-blue);
|
||||
color: var(--skype-blue);
|
||||
padding: 5px 12px;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="chat-widget">
|
||||
<!-- Sidebar -->
|
||||
<div id="sidebar">
|
||||
<div id="sidebar-header">
|
||||
<h2>상담 채널</h2>
|
||||
<div style="font-size: 12px; color: var(--text-sub); margin-top: 5px;">실시간 상담 대기 및 목록</div>
|
||||
</div>
|
||||
<div id="room-list">
|
||||
<!-- Room items will be injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div id="main-chat">
|
||||
<div id="chat-header">
|
||||
<div class="user-meta" id="active-user-meta">
|
||||
<div class="avatar" id="header-avatar">?</div>
|
||||
<div class="info">
|
||||
<div class="name" id="target-name">전체 모니터링</div>
|
||||
<div style="font-size: 12px; color: var(--text-sub);" id="target-status">실시간 대기 중</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<button id="exit-reply-btn"
|
||||
style="display:none; background:none; border:none; color:var(--skype-blue); cursor:pointer; font-weight:600; font-size:13px;">전체보기</button>
|
||||
<button id="auth-toggle-btn">로그인</button>
|
||||
<button id="signup-show-btn" onclick="location.href='/register'"
|
||||
style="background:none; border:1px solid #ddd; color:#666; padding:5px 12px; border-radius:15px; cursor:pointer; font-size:12px;">회원가입</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-messages">
|
||||
<!-- Messages will be injected here -->
|
||||
</div>
|
||||
|
||||
<!-- Reply Info (Skype 스타일에서는 헤더나 플로팅 바 대신 헤더 정보 활용 가능하지만 기존 로직 유지를 위해 숨김 처리 또는 통합) -->
|
||||
<div id="reply-info" style="display:none;"></div>
|
||||
|
||||
<div id="chat-input-area">
|
||||
<input type="file" id="image-input" accept="image/*" style="display: none;">
|
||||
<button id="image-btn" class="icon-btn" title="이미지 첨부">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</button>
|
||||
<input type="text" id="message-input" placeholder="메시지를 입력하세요...">
|
||||
<button id="send-btn">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div id="login-area" class="login-modal">
|
||||
<div class="login-content">
|
||||
<h3 style="margin-bottom: 20px;">사용자 로그인</h3>
|
||||
<input type="text" id="username" placeholder="아이디">
|
||||
<input type="password" id="password" placeholder="비밀번호">
|
||||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||
<button id="login-submit-btn" style="flex: 1;">로그인</button>
|
||||
<button id="login-cancel-btn" style="flex: 1; background: #eee; color: #333;">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/chat.js"></script>
|
||||
|
||||
</html>
|
||||
151
public/register.html
Normal file
151
public/register.html
Normal file
@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>회원가입 - IDC 상담 채널</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--skype-blue: #0078d4;
|
||||
--bg-light: #f3f2f1;
|
||||
--text-main: #201f1e;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--bg-light);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.register-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 30px;
|
||||
color: var(--skype-blue);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--skype-blue);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--skype-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin-top: 25px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--skype-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="register-container">
|
||||
<h2>상담 서비스 회원가입</h2>
|
||||
<div class="form-group">
|
||||
<label for="reg-id">아이디</label>
|
||||
<input type="text" id="reg-id" placeholder="사용할 아이디를 입력하세요">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="reg-pw">비밀번호</label>
|
||||
<input type="password" id="reg-pw" placeholder="비밀번호를 입력하세요">
|
||||
</div>
|
||||
<button id="register-btn">회원가입 하기</button>
|
||||
|
||||
<div class="footer-links">
|
||||
이미 계정이 있으신가요? <a href="/chat">로그인으로 돌아가기</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||
const username = document.getElementById('reg-id').value.trim();
|
||||
const password = document.getElementById('reg-pw').value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
alert('모든 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert('회원가입이 완료되었습니다! 로그인 페이지로 이동합니다.');
|
||||
window.location.href = '/chat';
|
||||
} else {
|
||||
alert('실패: ' + result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('API Error:', err);
|
||||
alert('서버와 통신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
278
server.js
Normal file
278
server.js
Normal file
@ -0,0 +1,278 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const mysql = require('mysql2/promise');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const fs = require('fs-extra');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
},
|
||||
maxHttpBufferSize: 1e8 // 100MB
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// Multer Storage Configuration
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, 'uploads');
|
||||
fs.ensureDirSync(uploadDir);
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// DB Pool Connection
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
// Create Table if not exists
|
||||
const initDB = async () => {
|
||||
try {
|
||||
// Create table
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_code VARCHAR(50),
|
||||
consultant_code VARCHAR(50),
|
||||
room_id VARCHAR(100),
|
||||
content TEXT,
|
||||
msg_type VARCHAR(10) DEFAULT 'text',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Ensure msg_type column exists (for existing tables)
|
||||
const [columns] = await pool.execute("SHOW COLUMNS FROM chat_messages LIKE 'msg_type'");
|
||||
if (columns.length === 0) {
|
||||
await pool.execute("ALTER TABLE chat_messages ADD COLUMN msg_type VARCHAR(10) DEFAULT 'text' AFTER content");
|
||||
console.log('DB schema updated: added msg_type column');
|
||||
}
|
||||
|
||||
console.log('Database initialized');
|
||||
} catch (err) {
|
||||
console.error('DB Init Error:', err);
|
||||
}
|
||||
};
|
||||
initDB();
|
||||
|
||||
// Image Upload API
|
||||
app.post('/api/upload', upload.single('image'), (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
console.log('Upload failed: No file uploaded');
|
||||
return res.status(400).json({ success: false, message: 'No file uploaded' });
|
||||
}
|
||||
const imageUrl = `/uploads/${req.file.filename}`;
|
||||
console.log('File uploaded successfully:', imageUrl);
|
||||
res.json({ success: true, imageUrl });
|
||||
} catch (err) {
|
||||
console.error('Upload API Error:', err);
|
||||
res.status(500).json({ success: false, message: 'Upload internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Basic Routes
|
||||
app.get('/chat', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.get('/admin/chat', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.get('/register', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'register.html'));
|
||||
});
|
||||
|
||||
// 루트로 접속 시 기본적으로 /chat으로 리다이렉트
|
||||
app.get('/', (req, res) => {
|
||||
res.redirect('/chat');
|
||||
});
|
||||
|
||||
// Login API (Supports both 'user' and 'clientinfo' tables)
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
try {
|
||||
// 1. 상담원(user) 테이블 먼저 조회
|
||||
let [rows] = await pool.execute('SELECT id, passwd, role FROM user WHERE id = ?', [username]);
|
||||
let isConsultant = true;
|
||||
|
||||
if (rows.length === 0) {
|
||||
// 2. 상담원이 없으면 일반 사용자(clientinfo) 테이블 조회
|
||||
[rows] = await pool.execute('SELECT id, passwd, role FROM clientinfo WHERE id = ?', [username]);
|
||||
isConsultant = false;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(401).json({ success: false, message: '사용자 계정을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const user = rows[0];
|
||||
const isValid = await bcrypt.compare(password, user.passwd);
|
||||
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.id,
|
||||
role: user.role || (isConsultant ? 'master' : 'user')
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login API error:', error);
|
||||
res.status(500).json({ success: false, message: '서버 내부 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
// User Registration API (clientinfo table)
|
||||
app.post('/api/register', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
try {
|
||||
// 중복 체크
|
||||
const [existing] = await pool.execute('SELECT id FROM clientinfo WHERE id = ?', [username]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(400).json({ success: false, message: '이미 존재하는 아이디입니다.' });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
await pool.execute(
|
||||
'INSERT INTO clientinfo (id, passwd, role, created_at) VALUES (?, ?, ?, NOW())',
|
||||
[username, hashedPassword, 'user']
|
||||
);
|
||||
|
||||
res.json({ success: true, message: '회원가입이 완료되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('Registration API error:', error);
|
||||
res.status(500).json({ success: false, message: '회원가입 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
const isConsultantRole = (role) => {
|
||||
if (!role) return false;
|
||||
// 상담원 관련 키워드 목록 (manager/manage 오타 및 콤마 구분 대응)
|
||||
const consultantKeywords = ['manage', 'manager', 'cloudflare', 'security', 'director', 'master', 'admin', 'consultant'];
|
||||
|
||||
// role이 "manager,cloudflare..." 처럼 여러 개일 경우를 대비해 체크
|
||||
const roles = String(role).toLowerCase();
|
||||
return consultantKeywords.some(keyword => roles.includes(keyword));
|
||||
};
|
||||
|
||||
// 룸 목록 관리를 위한 객체 (room_id -> { user_id, last_message, ... })
|
||||
const activeRooms = new Map();
|
||||
|
||||
const emitRoomList = () => {
|
||||
const roomList = Array.from(activeRooms.entries()).map(([room, data]) => ({
|
||||
room,
|
||||
user_id: data.user_id,
|
||||
username: data.user_id.startsWith('anon_') ? `익명(${data.user_id.substr(-5)})` : data.user_id
|
||||
}));
|
||||
io.to('consultants_group').emit('update_room_list', roomList);
|
||||
};
|
||||
|
||||
// Socket.io logic
|
||||
io.on('connection', (socket) => {
|
||||
console.log('A user connected:', socket.id);
|
||||
|
||||
socket.on('join_room', (data) => {
|
||||
const { room, role, user_id } = data;
|
||||
console.log('[DEBUG] Join Room Request:', data);
|
||||
|
||||
// 상담원의 경우 룸 전환 시 정리가 필요함
|
||||
if (isConsultantRole(role)) {
|
||||
// 다른 모든 룸에서 퇴장 순회 (본인 소켓 ID 방 제외)
|
||||
socket.rooms.forEach(r => {
|
||||
if (r !== socket.id) socket.leave(r);
|
||||
});
|
||||
|
||||
if (room === 'consultants_group') {
|
||||
socket.join('consultants_group');
|
||||
console.log(`[DEBUG] Consultant ${user_id} joined consultants_group`);
|
||||
// 입장 즉시 최신 룸 목록 전송
|
||||
emitRoomList();
|
||||
} else {
|
||||
// 특정 유저 룸 조인
|
||||
socket.join(room);
|
||||
console.log(`[DEBUG] Consultant ${user_id} moved to 1:1 room: ${room}`);
|
||||
}
|
||||
} else {
|
||||
// 일반 유저나 익명은 본인 방 가입
|
||||
socket.join(room);
|
||||
console.log(`[DEBUG] User ${user_id} joined room: ${room}`);
|
||||
|
||||
// 일반 유저의 룸을 활성 목록에 추가
|
||||
if (room.startsWith('room_')) {
|
||||
activeRooms.set(room, { user_id: user_id, socketId: socket.id });
|
||||
emitRoomList();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('send_message', async (data) => {
|
||||
const { room, sender, message, user_code, consultant_code, msg_type, role } = data;
|
||||
console.log(`[DEBUG] Message from ${sender} (role: ${role}) targeting room: ${room}`);
|
||||
|
||||
try {
|
||||
await pool.execute(
|
||||
'INSERT INTO chat_messages (user_code, consultant_code, room_id, content, msg_type, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
||||
[user_code || 'anonymous', consultant_code || null, room, message, msg_type || 'text']
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error saving message:', err);
|
||||
}
|
||||
|
||||
// 해당 룸 사용자에게 메시지 전송
|
||||
io.to(room).emit('receive_message', data);
|
||||
|
||||
// 상담원 그룹(전체 모니터링)에도 메시지 복사 전송
|
||||
// 유저의 메시지뿐만 아니라 상담원의 모든 답장도 다른 상담원 및 발신자 본인이 볼 수 있게 함
|
||||
if (room !== 'consultants_group') {
|
||||
io.to('consultants_group').emit('receive_message', data);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('User disconnected:', socket.id);
|
||||
|
||||
// 해당 소켓이 소유했던 룸 정보 제거
|
||||
for (let [room, data] of activeRooms.entries()) {
|
||||
if (data.socketId === socket.id) {
|
||||
activeRooms.delete(room);
|
||||
emitRoomList();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user