한글뷰어 기능수정 Ver.01
This commit is contained in:
84
OnlyOffice_Document_Server_소개_및_비교.md
Normal file
84
OnlyOffice_Document_Server_소개_및_비교.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# OnlyOffice Document Server 기술 분석 및 도입 타당성 검토
|
||||||
|
|
||||||
|
본 문서는 웹 브라우저 환경에서 오피스 문서(Word, Excel, PowerPoint)의 고정밀 뷰잉 및 실시간 편집 기능을 제공하는 오픈소스 솔루션인 **OnlyOffice Document Server**의 개념, 작동 원리, 장단점 및 본 프로젝트로의 도입 타당성을 상세히 비교 분석한 문서입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. OnlyOffice Document Server 란?
|
||||||
|
**OnlyOffice Document Server**는 웹 브라우저 내에서 Microsoft Office 포맷(`.docx`, `.xlsx`, `.pptx`) 문서를 원본과 거의 100% 동일하게 렌더링하고, 다중 사용자 실시간 협업 편집까지 가능하게 지원하는 **엔터프라이즈급 웹 오피스 서버 솔루션**입니다.
|
||||||
|
|
||||||
|
* **포지셔닝**: Google Docs, MS Office Online의 설치형 자립(Self-hosted) 대체제.
|
||||||
|
* **통합성**: Nextcloud, ownCloud, Redmine 등 수많은 그룹웨어 및 문서 관리 시스템(DMS)과 플러그인 형태로 통합되어 사용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 핵심 기술 및 렌더링 엔진 작동 원리 (HTML5 Canvas 방식)
|
||||||
|
기존의 오픈소스 라이브러리(Luckysheet, docx-preview)와 OnlyOffice의 가장 큰 차이점은 **렌더링 방식**에 있습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Luckysheet / docx-preview ]
|
||||||
|
엑셀/워드 XML 파싱 ➔ HTML DOM 요소(Table, Div)로 변환 ➔ 브라우저 렌더링
|
||||||
|
(※ 단점: 브라우저 기본 CSS 영향 및 복잡한 도형/양식 렌더링 한계)
|
||||||
|
|
||||||
|
[ OnlyOffice Document Server ]
|
||||||
|
서버에서 문서 구조 해석 ➔ 클라이언트에 레이아웃 정보 전달 ➔ HTML5 Canvas 픽셀 드로잉
|
||||||
|
(※ 장점: OS/브라우저 CSS와 무관하게 원본 폰트, 도형, 표 레이아웃을 100% 원본 그대로 표현)
|
||||||
|
```
|
||||||
|
|
||||||
|
* **HTML5 Canvas 렌더링**: 문서를 HTML 태그로 바꾸는 것이 아니라, 문서 한 페이지 한 페이지를 이미지/벡터 그리듯이 픽셀 단위로 Canvas 위에 그려줍니다. 따라서 엑셀의 도형, 화살표, 선, 차트 등 복잡한 개체들이 MS Excel 프로그램에서 볼 때와 똑같이 그려집니다.
|
||||||
|
* **자체 서식 보존**: OOXML(Office Open XML) 규격을 모태로 개발되어 워드, 엑셀, PPT 파일 포맷과의 호환성이 현존하는 오픈소스 웹 오피스 중 가장 높습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 주요 기능 (Key Features)
|
||||||
|
1. **고정밀 미리보기 (Viewing)**: 웹 뷰어 모드를 제공하여 폰트 장평, 자간, 표 너비, 삽입된 차트와 도형을 완벽하게 재현합니다.
|
||||||
|
2. **실시간 협업 편집 (Collaborative Editing)**: 한 문서를 여러 사용자가 브라우저에서 동시에 수정할 수 있는 공동 작업 기능(Co-editing)을 지원합니다.
|
||||||
|
3. **버전 관리 및 변경 추적**: 문서 내 수정 내역 추적(Track Changes) 및 이전 버전 복원 기능을 제공합니다.
|
||||||
|
4. **다양한 포맷 지원**: DOCX, XLSX, PPTX뿐만 아니라 ODT, ODS, ODP, TXT, CSV 및 PDF 열람을 통합 지원합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 기존 아키텍처 vs OnlyOffice 비교 분석
|
||||||
|
|
||||||
|
| 비교 항목 | 현재 방식 (오픈소스 라이브러리 + PDF 폴백) | OnlyOffice Document Server 도입 방식 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **렌더링 방식** | 클라이언트 직접 파싱 (Luckysheet, docx-preview 등) + 실패 시 서버 PDF 렌더링 | 전용 OnlyOffice Document Server(독립 서버) 구동 + HTML5 Canvas 드로잉 |
|
||||||
|
| **도형(Shapes) 지원** | ❌ **직접 뷰잉 시 도형 미지원** (PDF 변환 전환 후 확인 필요) | **도형, 화살표, 차트, 특수 서식 100% 완벽 지원** |
|
||||||
|
| **편집 기능** | ❌ **조회 전용 (미리보기만 지원)** | **웹 브라우저 내 직접 수정 및 저장 지원 (협업 가능)** |
|
||||||
|
| **서버 부하** | 낮음 (최초 1회 PDF 변환 시에만 서버 자원 소모) | 높음 (서버 상에 오피스 엔진 인스턴스가 상시 구동되어 메모리 소모량 큼) |
|
||||||
|
| **시스템 복잡도** | 낮음 (Node.js 서버에 가벼운 CLI 변환기만 배치) | 높음 (OnlyOffice Document Server를 별도 서버/컨테이너로 추가 구축해야 함) |
|
||||||
|
| **라이선스** | 무료 (오픈소스 + 자체 CLI 툴) | **AGPL v3** (오픈소스 버전은 최대 동시 편집 세션 20개 제한, 초과 시 상용 라이선스 필요) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. OnlyOffice Document Server 도입 연동 흐름
|
||||||
|
OnlyOffice를 프로젝트에 연동할 때의 기본적인 API 아키텍처 흐름입니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor User as 사용자 브라우저
|
||||||
|
participant Node as PM 백엔드 서버 (Node.js)
|
||||||
|
participant OO as OnlyOffice Document Server
|
||||||
|
participant Storage as MinIO 스토리지
|
||||||
|
|
||||||
|
User->>Node: 1. 문서 미리보기 요청
|
||||||
|
Node->>Node: 2. OnlyOffice 전용 연동 토큰(JWT) 및 문서 설정 JSON 생성
|
||||||
|
Node-->>User: 3. OnlyOffice Editor HTML/JS (설정값 포함) 반환
|
||||||
|
User->>OO: 4. 브라우저가 OnlyOffice JS 로드 및 문서 세션 요청
|
||||||
|
OO->>Storage: 5. MinIO로부터 원본 오피스 파일 다운로드
|
||||||
|
OO->>User: 6. Canvas 렌더링을 통한 고해상도 오피스 뷰어 화면 표출
|
||||||
|
```
|
||||||
|
|
||||||
|
### 필수 연동 API 개발 필요 사항 (Node.js 백엔드 구현)
|
||||||
|
1. **Document Config Generator**: OnlyOffice Editor가 구동되기 위해 필요한 파일 고유 키, 다운로드 URL, 유저 권한 정보 등을 담은 JSON 객체를 반환하는 API.
|
||||||
|
2. **Callback Handler API**: 사용자가 웹 오피스에서 문서를 수정하고 닫았을 때, OnlyOffice Server가 편집 완료된 파일을 백엔드로 전송(HTTP POST)해 주는데, 이를 수신하여 MinIO 및 DB에 갱신 저장하는 Callback API 개발 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 결론 및 제안
|
||||||
|
* **도입 권장 경우**:
|
||||||
|
만약 프로젝트의 요구사항이 단순 "문서 미리보기"를 넘어 **"웹 브라우저 상에서 엑셀/워드 파일을 직접 수정하고 협업 편집하여 저장하는 기능"**까지 포함해야 한다면, OnlyOffice Document Server의 도입은 **필수적**입니다. 현존하는 오픈소스 대안 중 최고의 호환성을 자랑합니다.
|
||||||
|
* **현재 프로젝트 기준의 조언**:
|
||||||
|
단순 미리보기 조회 환경에서는 OnlyOffice 구동을 위한 메모리(최소 4GB RAM 이상 독립 컨테이너 필요) 및 동시성 라이선스 제약(동시 접속 20대 이상 시 유료 라이선스 도입 필요)이 부담될 수 있습니다.
|
||||||
|
따라서, **조회 중심 서비스인 현재 구조에서는 기존의 `Luckysheet/docx-preview + PDF 상시 전환` 방식이 리소스 대비 최선의 효율**이며, 향후 **웹 오피스 직접 편집 기능**이 비즈니스 요구사항으로 추가될 때 OnlyOffice 도입을 검토하는 아키텍처 로드맵이 가장 합리적입니다.
|
||||||
2480
PM관리자화면개발산출물_20260611/관리자화면_통합대시보드_UI.html
Normal file
2480
PM관리자화면개발산출물_20260611/관리자화면_통합대시보드_UI.html
Normal file
File diff suppressed because it is too large
Load Diff
118
PM관리자화면개발산출물_20260611/관리자화면기획안.html
Normal file
118
PM관리자화면개발산출물_20260611/관리자화면기획안.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>관리자 화면 기능 설계 명세표</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Malgun Gothic', '맑은 고딕', sans-serif;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h2>🖥️ 관리자 화면(Admin Panel) 필수 기능 설계 명세표</h2>
|
||||||
|
<p>※ 이 파일을 Excel에서 <strong>[파일] -> [열기]</strong>로 실행하거나, 표 전체를 복사하여 Excel 시트에 붙여넣으시면 서식과 셀 구조가 그대로 유지되어 열립니다.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">대분류</th>
|
||||||
|
<th style="width: 20%;">메뉴명</th>
|
||||||
|
<th style="width: 35%;">상세 기능 명세</th>
|
||||||
|
<th style="width: 30%;">추가 고려사항 (고급 기능)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>프로젝트 관리</strong></td>
|
||||||
|
<td>프로젝트 등록 및 설정</td>
|
||||||
|
<td>신규 현장/공구의 생성 및 수정, 프로젝트별 용량 제한(storage_byte) 설정 및 잠금 토글(is_active) 관리</td>
|
||||||
|
<td>비공개/보안(Secret) 폴더에 대한 일반 사용자의 접근 신청 결재 및 임시 승인 처리</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>프로젝트 관리</strong></td>
|
||||||
|
<td>실시간 배너 공지</td>
|
||||||
|
<td>대시보드 상단 마르퀴 띠배너 공지사항(banner_notice) 등록 및 소켓 기반 실시간 송출 제어</td>
|
||||||
|
<td>배너 공지 예약 노출 설정 및 노출/송출 이력 관리</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>사용자 권한 관리</strong></td>
|
||||||
|
<td>사용자 계정 제어</td>
|
||||||
|
<td>사용자 계정 등록, 정보(부서/직급) 수정, 비밀번호 재설정 및 퇴사자 계정 잠금(is_resigned) 처리</td>
|
||||||
|
<td>외부 SSO(Sentinel) 계정 연동 및 중복 로그인 차단 옵션 관리</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>사용자 권한 관리</strong></td>
|
||||||
|
<td>프로젝트 권한 배정</td>
|
||||||
|
<td>현장별 참여 유저 목록 배정 및 권한 등급(Master/Sub-Master/Worker/Viewer) 마우스 클릭 지정</td>
|
||||||
|
<td>폴더 수준의 세부 접근 제어 리스트(ACL) 추가 연동</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>서버 및 리소스 모니터링</strong></td>
|
||||||
|
<td>용량 분석 현황</td>
|
||||||
|
<td>현장별 누적 스토리지 사용량, 남은 용량 및 파일/폴더 개수 대시보드 시각화</td>
|
||||||
|
<td>스토리지 임계값(예: 90%) 도달 시 관리자 자동 경고 알림</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>서버 및 리소스 모니터링</strong></td>
|
||||||
|
<td>압축 다운로드 관리</td>
|
||||||
|
<td>비동기 폴더 압축 다운로드(BullMQ) 작업의 진행 현황 모니터링 및 대기열 제어</td>
|
||||||
|
<td>임시 보존 기간이 지난 압축 임시파일 자동/일괄 영구 삭제를 통한 디스크 확보</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>시스템 감사 및 통계</strong></td>
|
||||||
|
<td>감사 로그 조회</td>
|
||||||
|
<td>파일 삭제, 이동, 다운로드 등 민감한 조작 행위(tb_log)의 날짜/유저/활동별 필터 검색</td>
|
||||||
|
<td>감사 로그 이력 보고서 인쇄 및 엑셀 백업 다운로드</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>시스템 감사 및 통계</strong></td>
|
||||||
|
<td>행동 분석 및 통계</td>
|
||||||
|
<td>사용자 UI 클릭 로그(tb_click_log) 기반 최다 접근 메뉴 및 최다 조회 도면/문서 사용 현황 분석</td>
|
||||||
|
<td>AI 요약 서비스(Gemini API) 호출 제한량(Quota) 관리 및 비용 통제</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>실시간 접속자</strong></td>
|
||||||
|
<td>실시간 접속 현황</td>
|
||||||
|
<td>현재 소켓 연결된 동시 접속 유저 목록 및 접속 IP와 현재 탐색 중인 아카이브 경로 실시간 표출</td>
|
||||||
|
<td>특정 유저 강제 소켓 연결 끊기(Kick) 기능</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>시스템 환경 및 정책 관리</strong></td>
|
||||||
|
<td>보관 및 삭제 정책 설정</td>
|
||||||
|
<td>기존 pageRenderer.js에 하드코딩된 '최소 유지 파일 개수(3개)' 및 '보존 기간(15일)' 임계치를 DB화하여, 관리자 화면에서 프로젝트별/글로벌 동적 설정 기능 제공</td>
|
||||||
|
<td>tb_project 테이블에 limit_file_count 및 limit_days 컬럼을 연동하여 프론트엔드가 변경된 기준값으로 실시간 바인딩되도록 API 구조 개선</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
343
PM관리자화면개발산출물_20260611/기능명세서.html
Normal file
343
PM관리자화면개발산출물_20260611/기능명세서.html
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PROJECT MASTER ver 4.0 기능명세서</title>
|
||||||
|
<!-- Google Fonts - Inter & Noto Sans KR -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #10b981;
|
||||||
|
--primary-dark: #047857;
|
||||||
|
--primary-light: #ecfdf5;
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #0f172a;
|
||||||
|
--text-muted: #475569;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', 'Noto Sans KR', sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Styling */
|
||||||
|
header {
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
padding-bottom: 24px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography & Structure */
|
||||||
|
h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
margin-top: 40px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-left: 5px solid var(--primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, li {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium Table Styles */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 24px 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) td {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background-color: var(--primary-light);
|
||||||
|
color: var(--primary-dark);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mermaid Wrapper */
|
||||||
|
.diagram-container {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 24px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight Boxes (Alerts) */
|
||||||
|
.alert {
|
||||||
|
background-color: var(--primary-light);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
border-radius: 4px 8px 8px 4px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin: 24px 0;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Mermaid.js CDN for dynamic rendering of charts -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
|
<script>
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: true,
|
||||||
|
theme: 'neutral',
|
||||||
|
securityLevel: 'loose'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>PROJECT MASTER ver 4.0 기능명세서</h1>
|
||||||
|
<p class="subtitle">시스템 아키텍처, 기능 명세 및 운영 가이드라인</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2>1. 시스템 아키텍처 및 연동 구조</h2>
|
||||||
|
<p>본 시스템은 실시간 협업과 대용량 건설 산출물 관리를 위해 웹소켓, 비동기 작업 큐 및 분산 저장소를 포함하는 모던 웹 아키텍처로 구성되어 있습니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-container">
|
||||||
|
<div class="mermaid">
|
||||||
|
graph TD
|
||||||
|
Client[Browser / Client] <-->|HTTP / Axios| NodeServer[Node.js Express Server]
|
||||||
|
Client <-->|Websocket / Socket.io| NodeServer
|
||||||
|
NodeServer <-->|SQL Queries| Postgres[(PostgreSQL DB)]
|
||||||
|
NodeServer <-->|PubSub / Queue| Redis[(Redis Server)]
|
||||||
|
NodeServer <-->|Presigned URL / PUT| MinIO[(MinIO Object Storage)]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>기술 스택 (Tech Stack)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>프론트엔드 (Frontend)</strong>: HTML5, Vanilla JavaScript, Vanilla CSS, Axios, Socket.io-client, OpenLayers (GIS), Cesium (3D 모델)</li>
|
||||||
|
<li><strong>백엔드 (Backend)</strong>: Node.js (Express), Socket.io, BullMQ (비동기 작업 큐), Winston (로깅)</li>
|
||||||
|
<li><strong>데이터베이스 (Database)</strong>: PostgreSQL (ver4 스키마), Redis (소켓 및 작업 큐 세션 관리)</li>
|
||||||
|
<li><strong>오브젝트 스토리지 (Storage)</strong>: MinIO / AWS S3 (Presigned URL 및 원격 정적 파일 보관)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>2. 사용자 권한 체계 (User Permission System)</h2>
|
||||||
|
<p>사용자 정보 및 소속 프로젝트별로 세분화된 권한 관리를 지원하며, UI 및 API 호출 수준에서 차단 및 필터링이 적용됩니다.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>권한 레벨</th>
|
||||||
|
<th>그룹/명칭</th>
|
||||||
|
<th>주요 권한 범위 및 설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Super / Dev</strong></td>
|
||||||
|
<td>super / dev</td>
|
||||||
|
<td>시스템 전체 관리 및 개발자 전용 모달 접근 권한. 비활성화된 프로젝트 우회 접근 지원.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Master</strong></td>
|
||||||
|
<td>관리자</td>
|
||||||
|
<td>프로젝트 전반의 산출물 수정/삭제/관리, 개별 폴더/파일별 권한 제어 및 대량 압축 다운로드 권한.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Sub-Master</strong></td>
|
||||||
|
<td>부관리자</td>
|
||||||
|
<td>신규 폴더 생성 및 삭제, 과업개요 정보 수정, 사용자 권한 배정 및 변경 처리.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Security Worker</strong></td>
|
||||||
|
<td>보안참여자</td>
|
||||||
|
<td>보안 설정이 적용된 비밀 폴더 및 파일에 대한 읽기/쓰기 권한 부여 및 관리.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Worker</strong></td>
|
||||||
|
<td>일반참여자</td>
|
||||||
|
<td>일반 산출물 업로드/다운로드, 개인 메모 작성 및 수정, AI 연동 문서 요약(Gemini) 기능 활용.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Viewer</strong></td>
|
||||||
|
<td>참관자</td>
|
||||||
|
<td>스토리지 조회 및 개별 파일 다운로드만 가능 (활동 로그 노출 방지 및 파일 추가/수정 버튼 숨김).</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>3. 핵심 모듈 명세 (Core Module Specifications)</h2>
|
||||||
|
|
||||||
|
<h3>3.1 아카이브 모듈 (Archive Module)</h3>
|
||||||
|
<p>프로젝트별 모든 산출물(도면, 공문, 과업 문서 등)을 관리하는 핵심 가상 스토리지 시스템입니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>폴더 구조 제어</strong>: 폴더 생성(createFolder), 이름 변경(renameTarget), 다른 폴더로의 계층식 위치 이동(relocateTarget)을 실시간 지원합니다.</li>
|
||||||
|
<li><strong>S3 Presigned URL 업로드</strong>: 대용량 업로드 안전성을 위해 서버의 자원을 거치지 않고, 스토리지로부터 Presigned URL을 직접 발급받아 브라우저가 직접 Put Object를 수행합니다.</li>
|
||||||
|
<li><strong>백그라운드 ZIP 압축 다운로드</strong>: 대량의 파일이나 폴더 통째 다운로드 시, BullMQ 큐에 작업을 위임하여 서버 부하를 최소화하고 완료 시 다운로드 링크를 팝업으로 제공합니다.</li>
|
||||||
|
<li><strong>가상 휴지통</strong>: 삭제된 파일은 즉시 지워지지 않고 휴지통으로 이동하며, 소유자 및 관리자의 승인을 통해 복원하거나 영구 삭제할 수 있습니다.</li>
|
||||||
|
<li><strong>뷰어 및 메모 연동</strong>: HWP/DWG 등 다양한 확장자 파일을 PDF로 자동 변환하여 브라우저에서 즉시 열람하는 통합 뷰어를 제공하며, 파일별 텍스트 메모 및 AI 요약 서비스를 연동합니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3.2 과업개요 모듈 (Overview Module)</h3>
|
||||||
|
<p>계약 사항 및 상세 마일스톤 정보, 지도 개요도를 하나의 통합 카드뷰 대시보드로 제공합니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>계약 및 사업 정보</strong>: 계약 금액(외화 금액 포함), Commencement Date, 준공 예정일, 공동 수급사 구성 지분율 및 대표인 정보를 보관합니다.</li>
|
||||||
|
<li><strong>개요도 드래그앤드롭 업로드</strong>: 현장 위치도 및 종합 개요도를 별도 파일 선택창 없이 드래그하여 직관적으로 변경 및 등록할 수 있습니다.</li>
|
||||||
|
<li><strong>과업 일정 캘린더</strong>: 연월 단위의 마일스톤 일정을 생성/관리하고 주요 진척도를 실시간 모니터링합니다.</li>
|
||||||
|
<li><strong>시차 연동 실시간 시계</strong>: 해외 프로젝트의 경우 해당 현장 국가의 표준시 시차 데이터를 바탕으로 실시간 현지 시각을 헤더에 동적으로 출력합니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3.3 공문 모듈 (Official Document Module)</h3>
|
||||||
|
<p>프로젝트 관계 기관 간 송수신된 공문 번호, 날짜, 수신처 목록과 문서를 체계적으로 매핑 및 보관합니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li>공문 정보 관리 (발송일, 제목, 수발신처, 문서 번호).</li>
|
||||||
|
<li>공문 매핑 첨부파일 연계 보관 및 1-클릭 다운로드 서비스.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3.4 GSIM (GIS 기반 위치 모델 모듈)</h3>
|
||||||
|
<p>건설 현장의 실제 위경도 좌표 및 고도(Height) 값과 연계하여 모델 및 데이터를 공간 정보와 매핑합니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li>2D(OpenLayers) 지도 및 3D(Cesium) 입체 모델 뷰어 탑재.</li>
|
||||||
|
<li>줌 레벨에 최적화된 마커 클러스터링(Clustering) 기법으로 대량의 포인트를 성능 저하 없이 표시.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. 실시간 동기화 및 로그 모듈</h2>
|
||||||
|
|
||||||
|
<h3>4.1 Websocket 다중 접속자 커서 (Socket.io)</h3>
|
||||||
|
<p>프로젝트 협업 효율성을 극대화하기 위해 다중 유저 동시 접속 시 각 유저의 Client 마우스 움직임을 추적하여 화면상에 실시간으로 커서 위치와 소속/이름표를 그려줍니다.</p>
|
||||||
|
|
||||||
|
<h3>4.2 실시간 로그 및 클릭 통계</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>푸터 실시간 한 줄 로그</strong>: 파일 생성, 삭제, 변환 등 유저가 수행한 핵심 행위가 푸터의 띠 배너 형태로 전 유저에게 실시간 브로드캐스트됩니다.</li>
|
||||||
|
<li><strong>클릭 활동 추적</strong>: 향후 화면 분석 및 건설 관리 통계용으로 유저가 누르는 주요 탭, 버튼 등의 액션을 수집하여 <code>tb_click_log</code> 테이블에 실시간으로 기록합니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. 안정성 및 장애 예방 설계 (Reliability & Robustness)</h2>
|
||||||
|
|
||||||
|
<div class="alert">
|
||||||
|
<div class="alert-title">💡 최신 패치 반영 사항 (System Resilience)</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>캐시 잠김 해제 미들웨어</strong>: GET 요청 중 정적 자원을 제외한 비동기 동적 API 요청에 대하여 브라우저 304 캐시를 완전히 강제 무효화함으로써, 이전 에러 데이터에 의한 화면 중단 현상을 원천 방지하였습니다.</li>
|
||||||
|
<li><strong>서버 Crash 방어 널가드 (Null-Guard)</strong>: DB 조회 쿼리 등에서 일시적인 커넥션 유실이나 스키마 에러가 나더라도 백엔드 프로세스가 통째로 죽지 않도록, 주요 API 핸들러 전반에 예외 위임(try-catch) 및 Null 체크 조건문을 완비하였습니다.</li>
|
||||||
|
<li><strong>클라이언트 실시간 디버거</strong>: 사용자 브라우저 상에서 유발된 자바스크립트 크래시 및 미처리 거부(Unhandled Rejection) 오류를 백엔드 콘솔로 자동 수집/출력해 주는 에러 추적 스크립트를 내장하였습니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
377
PM관리자화면개발산출물_20260611/메뉴정의서.html
Normal file
377
PM관리자화면개발산출물_20260611/메뉴정의서.html
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PM_ver4 통합 관리자 메뉴정의서</title>
|
||||||
|
<!-- Google Fonts - Inter & Noto Sans KR -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #1e5149;
|
||||||
|
/* Forest Green */
|
||||||
|
--primary-dark: #142e29;
|
||||||
|
--primary-light: #e9eeed;
|
||||||
|
--border: #d2dcdb;
|
||||||
|
--bg: #f4f7f6;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #1f2937;
|
||||||
|
--text-muted: #4b746d;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', 'Noto Sans KR', sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Controls */
|
||||||
|
.filter-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover,
|
||||||
|
.filter-btn.active {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Definition Table */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) td {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge-cat {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-table {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #334155;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0369a1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules list style */
|
||||||
|
.rules-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-list li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>PM_ver4 통합 관리자 메뉴정의서</h1>
|
||||||
|
<p class="subtitle">관리자 화면(Admin Panel) 1단계 및 2단계 메뉴 기능 명세 및 테이블 매핑 테이블</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filter Buttons -->
|
||||||
|
<div class="filter-controls">
|
||||||
|
<button class="filter-btn active" onclick="filterMenu('all')">전체보기</button>
|
||||||
|
<button class="filter-btn" onclick="filterMenu('dashboard')">1. Dashboards</button>
|
||||||
|
<button class="filter-btn" onclick="filterMenu('project')">2. 프로젝트 관리</button>
|
||||||
|
<button class="filter-btn" onclick="filterMenu('user')">3. 사용자 관리</button>
|
||||||
|
<button class="filter-btn" onclick="filterMenu('system')">4. 시스템 감사 및 환경</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu Table -->
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">1단계 메뉴</th>
|
||||||
|
<th style="width: 15%;">2단계 메뉴</th>
|
||||||
|
<th style="width: 15%;">메뉴 ID / Hash</th>
|
||||||
|
<th style="width: 25%;">주요 기능 명세 (Functional Specifications)</th>
|
||||||
|
<th style="width: 15%;">관련 DB 테이블</th>
|
||||||
|
<th style="width: 15%;">비고 및 비즈니스 규칙</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="menu-tbody">
|
||||||
|
<!-- Row 1 -->
|
||||||
|
<tr data-category="dashboard">
|
||||||
|
<td><strong>1. Dashboards</strong></td>
|
||||||
|
<td><span class="badge-cat">종합 용량 및 접속자</span></td>
|
||||||
|
<td class="menu-id">menu-dashboard<br>#dashboard</td>
|
||||||
|
<td>
|
||||||
|
• 전체 디스크 용량, 실시간 소켓 접속자 수, Redis 대기 작업 수 요약 노출.<br>
|
||||||
|
• 현장별 사용 용량, 백분율, 파일 수량 프로그레스 바 표시.<br>
|
||||||
|
• 실시간 소켓 연결 정보 그리드 및 사용자 강제퇴장(Kick) 기능.
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-table">tb_project</span><br>
|
||||||
|
<span class="badge-table">tb_data</span><br>
|
||||||
|
<span class="badge-table">Redis (Queue)</span>
|
||||||
|
</td>
|
||||||
|
<td>소켓 세션 맵과 실시간 동기화하여 퇴장 처리 즉시 실행.</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 2 -->
|
||||||
|
<tr data-category="project">
|
||||||
|
<td><strong>2. 프로젝트 관리</strong></td>
|
||||||
|
<td><span class="badge-cat">프로젝트 관리</span></td>
|
||||||
|
<td class="menu-id">menu-project-mgmt<br>#project-mgmt</td>
|
||||||
|
<td>
|
||||||
|
• 프로젝트 목록 조회 및 신규 등록/수정/삭제 모달 팝업.<br>
|
||||||
|
• 프로젝트 구분 카테고리 지정.<br>
|
||||||
|
• 행 클릭 시 우측에 참여 사용자 등급 조회 및 즉각 수정/배정제외.<br>
|
||||||
|
• 미배정 사용자 다중 선택 일괄 추가 배정 팝업.
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-table">tb_project</span><br>
|
||||||
|
<span class="badge-table">code_detail</span><br>
|
||||||
|
<span class="badge-table">tb_permission</span><br>
|
||||||
|
<span class="badge-table">tb_user</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul class="rules-list">
|
||||||
|
<li>등록 시 ID 직접 입력, 수정 시 ID 비활성.</li>
|
||||||
|
<li><strong>삭제 제한</strong>: 관련 테이블(tb_data, tb_official_doc_file, tb_banner_notice 등)에 현장 ID
|
||||||
|
사용 이력이 있으면 삭제 불가능.</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 3 -->
|
||||||
|
<tr data-category="project">
|
||||||
|
<td><strong>2. 프로젝트 관리</strong></td>
|
||||||
|
<td><span class="badge-cat">실시간 배너 공지</span></td>
|
||||||
|
<td class="menu-id">menu-banner-notice<br>#banner-notice</td>
|
||||||
|
<td>
|
||||||
|
• 배너 공지사항 작성 및 등록 (대상 현장, 송출 기간, 편집용 등록일 지정).<br>
|
||||||
|
• 송출 상태(송출중, 예약됨, 만료) 및 등록일 범위 필터 검색 기능.<br>
|
||||||
|
• 진행 중인 공지 개별 즉시 송출중지 처리.
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-table">tb_banner_notice</span><br>
|
||||||
|
<span class="badge-table">code_detail</span><br>
|
||||||
|
<span class="badge-table">tb_project</span>
|
||||||
|
</td>
|
||||||
|
<td>시작일/종료일 경과 여부에 따라 송출 상태 실시간 계산 렌더링.</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 4 -->
|
||||||
|
<tr data-category="user">
|
||||||
|
<td><strong>3. 사용자 관리</strong></td>
|
||||||
|
<td><span class="badge-cat">사용자 관리</span></td>
|
||||||
|
<td class="menu-id">menu-user-mgmt<br>#user-mgmt</td>
|
||||||
|
<td>
|
||||||
|
• 전체 사용자 정보 조회 및 신규 계정 등록/정보 수정/삭제 모달.<br>
|
||||||
|
• 권한 그룹 및 재직/퇴직잠금 상태 배지 설정.<br>
|
||||||
|
• 행 클릭 시 해당 유저의 프로젝트 참여 목록(권한 등급 포함) 우측 연동 리스트업.
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-table">tb_user</span><br>
|
||||||
|
<span class="badge-table">code_detail</span><br>
|
||||||
|
<span class="badge-table">tb_permission</span><br>
|
||||||
|
<span class="badge-table">tb_project</span>
|
||||||
|
</td>
|
||||||
|
<td><strong>삭제 제한</strong>: 권한 테이블(tb_permission)에 프로젝트 참여 권한 정보가 존재하면 계정 삭제 불가능.</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 5 -->
|
||||||
|
<tr data-category="system">
|
||||||
|
<td><strong>4. 시스템 감사 및 환경</strong></td>
|
||||||
|
<td><span class="badge-cat">감사 로그 조회</span></td>
|
||||||
|
<td class="menu-id">menu-audit-logs<br>#audit-logs</td>
|
||||||
|
<td>
|
||||||
|
• 파일 삭제, 이동, 다운로드 등 보안 감사 대상 활동 조회.<br>
|
||||||
|
• 유저 ID 검색 및 액션 타입(activity) 필터 검색 기능.
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-table">tb_log</span>
|
||||||
|
</td>
|
||||||
|
<td>감사 추적용 조회 전용 화면.</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 6 -->
|
||||||
|
<tr data-category="system">
|
||||||
|
<td><strong>4. 시스템 감사 및 환경</strong></td>
|
||||||
|
<td><span class="badge-cat">보관 및 삭제 정책 설정</span></td>
|
||||||
|
<td class="menu-id">menu-delete-policy<br>#delete-policy</td>
|
||||||
|
<td>
|
||||||
|
• 시스템 공통 자동 보존 및 파일 삭제 임계 기준 설정 폼.<br>
|
||||||
|
• 폼 데이터 변경 시 작동 시나리오 문구 동적 요약 안내.<br>
|
||||||
|
• 정기 자동 삭제 스케줄러 배치 구동 이력 로그 리스트업.
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-table">tb_system_policy</span><br>
|
||||||
|
<span class="badge-table">tb_auto_clean_log</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul class="rules-list">
|
||||||
|
<li>설정값 변경 저장 시 로그 이력의 대상에는 'SYSTEM' 기입.</li>
|
||||||
|
<li>배치는 수동 변경 값을 즉각 바인딩하여 다음 기동 시 적용.</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 7 -->
|
||||||
|
<tr data-category="system">
|
||||||
|
<td><strong>4. 시스템 감사 및 환경</strong></td>
|
||||||
|
<td><span class="badge-cat">공통 코드 관리</span></td>
|
||||||
|
<td class="menu-id">menu-code-mgmt<br>#code-mgmt</td>
|
||||||
|
<td>
|
||||||
|
• 대분류 코드 마스터 등록/수정/삭제 모달.<br>
|
||||||
|
• 대분류 선택 시 하단에 소속 소분류 세부 코드 실시간 필터 로드.<br>
|
||||||
|
• 소분류 코드 등록/수정/삭제 모달.<br>
|
||||||
|
• 소분류 base_code (대분류_소분류 결합 코드) 자동 완성 저장.
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-table">code_master</span><br>
|
||||||
|
<span class="badge-table">code_detail</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul class="rules-list">
|
||||||
|
<li>대분류 미선택 시 하단 세부코드 등록 차단 및 경고.</li>
|
||||||
|
<li><strong>삭제 제한</strong>: 대분류 코드에 속한 하위 소분류 세부 코드(code_detail)가 존재하면 대분류 삭제 불가능.</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interactive Filtering Script -->
|
||||||
|
<script>
|
||||||
|
function filterMenu(category) {
|
||||||
|
// Update active filter button styling
|
||||||
|
const buttons = document.querySelectorAll('.filter-btn');
|
||||||
|
buttons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
|
// Highlight clicked button
|
||||||
|
const clickedBtn = event.currentTarget || event.target;
|
||||||
|
if (clickedBtn && clickedBtn.classList) {
|
||||||
|
clickedBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/Hide rows
|
||||||
|
const rows = document.querySelectorAll('#menu-tbody tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (category === 'all' || row.getAttribute('data-category') === category) {
|
||||||
|
row.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
row.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
424
PM관리자화면개발산출물_20260611/워크플로우_데이터흐름도.html
Normal file
424
PM관리자화면개발산출물_20260611/워크플로우_데이터흐름도.html
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PM 프로젝트 워크플로우 및 데이터 흐름도</title>
|
||||||
|
<!-- Google Fonts - Inter & Noto Sans KR -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Mermaid.js 라이브러리 (CDN) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
|
<script>
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: true,
|
||||||
|
theme: 'default',
|
||||||
|
securityLevel: 'loose',
|
||||||
|
themeVariables: {
|
||||||
|
fontFamily: 'Inter, Noto Sans KR, sans-serif',
|
||||||
|
primaryColor: '#f43f5e',
|
||||||
|
primaryTextColor: '#fff',
|
||||||
|
lineColor: '#cbd5e1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #f43f5e;
|
||||||
|
--primary-dark: #be123c;
|
||||||
|
--primary-light: #fff1f2;
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #0f172a;
|
||||||
|
--text-muted: #475569;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.05), 0 4px 6px -4px rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', 'Noto Sans KR', sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Styling */
|
||||||
|
header {
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
padding-bottom: 24px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1e293b;
|
||||||
|
border-left: 5px solid var(--primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin-top: 40px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #334155;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card / Diagram Area Style */
|
||||||
|
.diagram-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 20px 0 35px 0;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight text */
|
||||||
|
code {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #e11d48;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--primary-light);
|
||||||
|
color: var(--primary-dark);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>PM 프로젝트 워크플로우 및 데이터 흐름도</h1>
|
||||||
|
<p class="subtitle">로컬 개발 인프라 및 핵심 기능(업로드/압축)에 대한 시각화 명세서</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p>본 문서는 <code>PM_ver4</code> 프로젝트의 시스템 아키텍처, 기능별 워크플로우 및 엔티티 간 데이터 흐름을 시각적으로 구현하여 이해를 돕기 위한 HTML 문서입니다.</p>
|
||||||
|
|
||||||
|
<!-- 1. 전체 아키텍처 -->
|
||||||
|
<h2>1. 전체 시스템 아키텍처 및 데이터 흐름도</h2>
|
||||||
|
<p>사용자 브라우저에서 출발하여 웹 서버(Express), PostgreSQL, Redis 및 로컬 MinIO(S3) 스토리지까지 연결되는 데이터 흐름도입니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-card">
|
||||||
|
<div class="mermaid">
|
||||||
|
graph LR
|
||||||
|
%% 1. 사용자 영역 (가장 좌측 배치)
|
||||||
|
subgraph Client [사용자 브라우저]
|
||||||
|
UI["HTML CSS JS views"]
|
||||||
|
SocketClient["Socket.io Client"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% 2. 백엔드 웹서버 영역 (중앙 배치)
|
||||||
|
subgraph WebServer [백엔드 서버 Port 6565]
|
||||||
|
Express["Express App"]
|
||||||
|
Router["Router / Controller"]
|
||||||
|
AuthMid["SSO / Login Bypass"]
|
||||||
|
SocketServer["Socket.io Server"]
|
||||||
|
BullQueue["BullMQ Producer"]
|
||||||
|
S3SDK["aws-sdk s3 - MinIO 연동"]
|
||||||
|
PGDriver["pg - PostgreSQL 연동"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% 3. 백그라운드 워커 영역 (중앙 하단 배치)
|
||||||
|
subgraph BackgroundWorker [비동기 워커 프로세스]
|
||||||
|
Worker["BullMQ Worker - 압축 수행"]
|
||||||
|
ExternalCLI["programs - pdf_thumb / encryp"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% 4. 인프라 영역 (가장 우측 배치)
|
||||||
|
subgraph Infrastructure [Docker 가상 환경]
|
||||||
|
PostgresDB["PostgreSQL DB"]
|
||||||
|
RedisDB["Redis Broker"]
|
||||||
|
MinIOStorage["MinIO S3"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% 좌측 (Client) -> 중앙 (WebServer) 흐름
|
||||||
|
UI -->|HTTP Request| Express
|
||||||
|
Express --> UI
|
||||||
|
SocketClient ---|Socket Event| SocketServer
|
||||||
|
|
||||||
|
%% 웹서버 내부 로직 흐름
|
||||||
|
Express --> AuthMid
|
||||||
|
AuthMid --> Router
|
||||||
|
|
||||||
|
%% 중앙 (WebServer) -> 우측 (Infrastructure) 데이터 흐름
|
||||||
|
Router -->|Query| PGDriver
|
||||||
|
PGDriver --> PostgresDB
|
||||||
|
|
||||||
|
Router -->|Push Job| BullQueue
|
||||||
|
BullQueue --> RedisDB
|
||||||
|
|
||||||
|
Router -->|Generate URL| S3SDK
|
||||||
|
S3SDK --> MinIOStorage
|
||||||
|
|
||||||
|
%% 좌측 (Client) -> 우측 (Infrastructure) 다이렉트 업로드 흐름
|
||||||
|
UI -->|Direct Upload| MinIOStorage
|
||||||
|
|
||||||
|
%% 워커 동작 흐름 (중앙 하단 -> 우측 및 외부)
|
||||||
|
Worker -->|Pop Job| RedisDB
|
||||||
|
Worker -->|Execute CLI| ExternalCLI
|
||||||
|
Worker -->|Read Write Files| MinIOStorage
|
||||||
|
Worker -->|Update Status| PGDriver
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. 주요 기능별 핵심 워크플로우 -->
|
||||||
|
<h2>2. 주요 기능별 핵심 워크플로우</h2>
|
||||||
|
|
||||||
|
<h3>2.1 파일 업로드 워크플로우 <span class="badge">Presigned URL 방식</span></h3>
|
||||||
|
<p>서버 리소스를 보존하기 위해 브라우저에서 스토리지(MinIO)로 파일을 직접 올릴 수 있게 구현된 프로세스입니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-card">
|
||||||
|
<div class="mermaid">
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor User as 사용자 (브라우저)
|
||||||
|
participant Server as 백엔드 서버 (Node.js)
|
||||||
|
participant MinIO as 로컬 MinIO 스토리지
|
||||||
|
participant DB as 데이터베이스 (PostgreSQL)
|
||||||
|
|
||||||
|
User->>Server: 1. 업로드 링크 요청 (POST /:projectId/archive/generateUploadUrl)
|
||||||
|
Note over Server: Bucket명 변환 미들웨어 작동<br>(PM_TEST_01 -> pm-test-01)
|
||||||
|
Server->>MinIO: 2. S3 SDK를 통한 Presigned PUT URL 요청
|
||||||
|
MinIO-->>Server: 3. 제한 시간 설정된 Presigned URL 반환
|
||||||
|
Server-->>User: 4. Presigned URL 전달
|
||||||
|
|
||||||
|
User->>MinIO: 5. 해당 Presigned URL로 직접 파일 업로드 (HTTP PUT)
|
||||||
|
Note over User,MinIO: 브라우저에서 스토리지로 다이렉트 전송
|
||||||
|
MinIO-->>User: 6. 업로드 완료 응답 (HTTP 200 OK)
|
||||||
|
|
||||||
|
User->>Server: 7. 업로드 정보 DB 반영 요청
|
||||||
|
Server->>DB: 8. tb_data / _test_tb_data 테이블에 파일 정보 INSERT
|
||||||
|
DB-->>Server: 9. 삽입 성공
|
||||||
|
Server-->>User: 10. 업로드 최종 완료 처리
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>2.2 폴더 비동기 압축 및 다운로드 워크플로우 <span class="badge">BullMQ + Redis 방식</span></h3>
|
||||||
|
<p>오래 걸리는 압축 다운로드 작업을 백그라운드에서 비동기로 수행하고, 소켓으로 클라이언트에 완료 알림을 제공하는 워크플로우입니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-card">
|
||||||
|
<div class="mermaid">
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor User as 사용자 (브라우저)
|
||||||
|
participant Server as 백엔드 서버 (Node.js)
|
||||||
|
participant Redis as Redis (6379)
|
||||||
|
participant Worker as 백그라운드 Worker
|
||||||
|
participant MinIO as 로컬 MinIO 스토리지
|
||||||
|
participant DB as 데이터베이스 (PostgreSQL)
|
||||||
|
|
||||||
|
User->>Server: 1. 폴더 압축 다운로드 요청
|
||||||
|
Server->>DB: 2. tb_download_folder에 'PENDING' 상태로 이력 기록
|
||||||
|
Server->>Redis: 3. BullMQ를 통해 압축 Job 발행 (Push)
|
||||||
|
Server-->>User: 4. 압축 작업 시작 안내 응답 (즉시 반환, 화면 안멈춤)
|
||||||
|
|
||||||
|
loop Worker 감시
|
||||||
|
Worker->>Redis: 5. 대기 중인 Job 가져오기 (Pop)
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over Worker: 6. 폴더 내 모든 파일 탐색 및<br>zip 압축 파일 로컬 생성
|
||||||
|
Worker->>MinIO: 7. 생성된 zip 파일을 S3 스토리지에 업로드
|
||||||
|
Worker->>DB: 8. tb_download_folder 상태를 'COMPLETED'로 갱신,<br>zip_key 및 만료일(expire_date) 기록
|
||||||
|
|
||||||
|
Note over Server,User: 소켓(Socket.io) 또는 폴링 감시
|
||||||
|
Server->>User: 9. 압축 완료 알림 전송 (Socket.io)
|
||||||
|
User->>Server: 10. 내 다운로드 리스트 요청 (GET /getMyDownloadList)
|
||||||
|
Server->>DB: 11. 완료된 zip 다운로드 정보 조회
|
||||||
|
DB-->>Server: 12. zip 키값 반환
|
||||||
|
Server-->>User: 13. MinIO zip 다운로드 다운로드 링크 반환
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. ERD 맵핑 관계 -->
|
||||||
|
<h2>3. 주요 데이터 스키마 간 맵핑 관계 (Core ERD)</h2>
|
||||||
|
<p>시스템 비즈니스 로직의 핵심이 되는 6개 주요 테이블 간의 참조 관계와 주요 필드 중심의 ERD 다이어그램입니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-card">
|
||||||
|
<div class="mermaid">
|
||||||
|
erDiagram
|
||||||
|
tb_user {
|
||||||
|
varchar user_id PK
|
||||||
|
varchar user_nm
|
||||||
|
varchar company
|
||||||
|
varchar dept
|
||||||
|
varchar group
|
||||||
|
}
|
||||||
|
|
||||||
|
tb_project {
|
||||||
|
varchar project_id PK
|
||||||
|
varchar user_id FK
|
||||||
|
varchar project_nm
|
||||||
|
varchar short_nm
|
||||||
|
boolean overview
|
||||||
|
boolean official_doc
|
||||||
|
boolean gsim
|
||||||
|
}
|
||||||
|
|
||||||
|
tb_data {
|
||||||
|
integer data_id PK
|
||||||
|
varchar project_id FK
|
||||||
|
varchar user_id FK
|
||||||
|
boolean is_folder
|
||||||
|
bigint data_size
|
||||||
|
text object_key
|
||||||
|
bigint popup_size
|
||||||
|
bigint preview_size
|
||||||
|
text ai_summary
|
||||||
|
timestamp create_date
|
||||||
|
}
|
||||||
|
|
||||||
|
tb_download_folder {
|
||||||
|
integer download_id PK
|
||||||
|
varchar project_id FK
|
||||||
|
varchar user_id FK
|
||||||
|
varchar status
|
||||||
|
text zip_key
|
||||||
|
timestamp expire_date
|
||||||
|
boolean made
|
||||||
|
}
|
||||||
|
|
||||||
|
tb_official_doc_file {
|
||||||
|
integer doc_id PK
|
||||||
|
varchar project_id FK
|
||||||
|
varchar uploader FK
|
||||||
|
varchar doc_number
|
||||||
|
varchar doc_date
|
||||||
|
text doc_title
|
||||||
|
text doc_title_summary
|
||||||
|
text doc_content_summary
|
||||||
|
bigint popup_size
|
||||||
|
bigint preview_size
|
||||||
|
}
|
||||||
|
|
||||||
|
tb_click_log {
|
||||||
|
integer click_log_id PK
|
||||||
|
varchar project_id FK
|
||||||
|
varchar user_id FK
|
||||||
|
varchar activity
|
||||||
|
varchar user_ip
|
||||||
|
text_array path_arr
|
||||||
|
int_array data_id_arr
|
||||||
|
}
|
||||||
|
|
||||||
|
%% Relationships
|
||||||
|
tb_user ||--o{ tb_project : "manages"
|
||||||
|
tb_project ||--o{ tb_data : "contains"
|
||||||
|
tb_user ||--o{ tb_data : "creates"
|
||||||
|
tb_project ||--o{ tb_download_folder : "contains"
|
||||||
|
tb_user ||--o{ tb_download_folder : "requests"
|
||||||
|
tb_project ||--o{ tb_official_doc_file : "contains"
|
||||||
|
tb_user ||--o{ tb_official_doc_file : "uploads"
|
||||||
|
tb_project ||--o{ tb_click_log : "records"
|
||||||
|
tb_user ||--o{ tb_click_log : "performs"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. 사용자 관리 업무 흐름 -->
|
||||||
|
<h2>4. 사용자 관리 업무 흐름</h2>
|
||||||
|
<p>신규 사용자 계정을 생성한 뒤, 프로젝트 현장을 등록하고, 해당 현장에 사용자를 배정하여 시스템 기능을 가동하는 일련의 흐름입니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-card">
|
||||||
|
<div class="mermaid">
|
||||||
|
graph TD
|
||||||
|
A["👤 1. 사용자 등록 (tb_user)<br>사용자 아이디, 패스워드, 이름, 회사/소속부서 등록"] --> B["🏗️ 2. 프로젝트 등록 (tb_project)<br>현장 고유
|
||||||
|
ID, 공식 명칭, 카테고리 구분, 용량제한 설정"]
|
||||||
|
B --> C["🔗 3. 프로젝트 사용자 등록 (tb_permission)<br>등록된 사용자를 특정 현장에 권한 등급(lev)과 함께 배정 및 연결"]
|
||||||
|
C --> D["💻 4. 시스템 사용 (tb_data / tb_official_doc_file)<br>배정받은 권한 레벨(Owner, Sub-Master, Worker, Viewer)에
|
||||||
|
맞춰 시스템 기능 활용"]
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
style A fill:#fff1f2,stroke:#f43f5e,stroke-width:2px;
|
||||||
|
style B fill:#fff1f2,stroke:#f43f5e,stroke-width:2px;
|
||||||
|
style C fill:#fff1f2,stroke:#f43f5e,stroke-width:2px;
|
||||||
|
style D fill:#be123c,stroke:#be123c,stroke-width:2px,color:#fff;
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
2247
PM관리자화면개발산출물_20260611/테이블명세서.html
Normal file
2247
PM관리자화면개발산출물_20260611/테이블명세서.html
Normal file
File diff suppressed because it is too large
Load Diff
793
PM관리자화면개발산출물_20260611/통합관리자대시보드_구현가이드.html
Normal file
793
PM관리자화면개발산출물_20260611/통합관리자대시보드_구현가이드.html
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>통합 관리자 대시보드 구현 계획 및 테이블 명세 가이드</title>
|
||||||
|
<!-- Google Fonts - Inter & Noto Sans KR -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Mermaid JS for Dynamic Diagrams -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
||||||
|
<script>
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: true,
|
||||||
|
theme: 'forest',
|
||||||
|
themeVariables: {
|
||||||
|
primaryColor: '#1e5149',
|
||||||
|
primaryTextColor: '#fff',
|
||||||
|
lineColor: '#1e5149',
|
||||||
|
secondaryColor: '#142e29'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #1e5149;
|
||||||
|
--primary-dark: #142e29;
|
||||||
|
--primary-light: #e9eeed;
|
||||||
|
--primary-border: #d2dcdb;
|
||||||
|
--accent: #4db251;
|
||||||
|
--bg: #f4f7f6;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #1f2937;
|
||||||
|
--text-muted: #4b5563;
|
||||||
|
--code-bg: #1e293b;
|
||||||
|
--code-text: #f8fafc;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', 'Noto Sans KR', sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Navigation */
|
||||||
|
aside {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: linear-gradient(180deg, var(--primary-dark) 0%, #0c1a18 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside a {
|
||||||
|
color: #b3c5c2;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside a:hover, aside li.active a {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside .category-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #628781;
|
||||||
|
margin: 16px 0 8px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
main {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 40px 50px;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 2px solid var(--primary-border);
|
||||||
|
padding-bottom: 24px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
header .subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Sections */
|
||||||
|
section {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--primary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.section-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Callouts (GitHub alerts) */
|
||||||
|
.callout {
|
||||||
|
border-left: 4px solid;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-note {
|
||||||
|
background-color: #f0f7ff;
|
||||||
|
border-color: #0066cc;
|
||||||
|
color: #004080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-important {
|
||||||
|
background-color: #fff8f8;
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--primary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--primary-border);
|
||||||
|
color: var(--text-main);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) td {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Blocks */
|
||||||
|
pre {
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
color: var(--code-text);
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Consolas', 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Consolas', 'Courier New', Courier, monospace;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-get { background-color: #0ea5e9; }
|
||||||
|
.method-post { background-color: #22c55e; }
|
||||||
|
.method-put { background-color: #f59e0b; }
|
||||||
|
.method-delete { background-color: #ef4444; }
|
||||||
|
|
||||||
|
.api-url {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Styling */
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diagram Container */
|
||||||
|
.diagram-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 24px 0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--primary-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Sidebar Navigation -->
|
||||||
|
<aside>
|
||||||
|
<h2>📁 PM_ver4 Admin Guide</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#overview">가이드 소개</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="category-title">01. 시스템 아키텍처</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#architecture">전체 구조 & 로직</a></li>
|
||||||
|
<li><a href="#migration">단계별 구현 계획</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="category-title">02. 데이터 모델 (ERD)</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#schema-design">스키마 구조 & 관계</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="category-title">03. 화면별 데이터 매핑</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#screen-dashboard">1. 종합 용량 및 접속자</a></li>
|
||||||
|
<li><a href="#screen-project">2. 프로젝트 관리</a></li>
|
||||||
|
<li><a href="#screen-banner">3. 실시간 배너 공지</a></li>
|
||||||
|
<li><a href="#screen-user">4. 사용자 관리</a></li>
|
||||||
|
<li><a href="#screen-audit">5. 감사 로그 조회</a></li>
|
||||||
|
<li><a href="#screen-policy">6. 자동 삭제 정책 설정</a></li>
|
||||||
|
<li><a href="#screen-codes">7. 공통 코드 관리</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="category-title">04. 백엔드 API 설계</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#api-endpoints">핵심 CRUD API 목록</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main>
|
||||||
|
<header id="overview">
|
||||||
|
<h1>통합 관리자 대시보드 구현 계획 및 테이블 명세 가이드</h1>
|
||||||
|
<p class="subtitle">관리자 화면(Admin Panel) 마이그레이션 및 데이터베이스 연동 설계서</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Section 1 -->
|
||||||
|
<section id="architecture">
|
||||||
|
<h2 class="section-title">1. 전체 시스템 아키텍처 및 구현 계획</h2>
|
||||||
|
<p>본 설계서는 <code>관리자화면_통합대시보드_UI제안.html</code> 정적 파일의 사용자 동작 시뮬레이션을 프로덕션 급 서버 인프라에 안착하기 위한 설계 명세입니다. 시스템 환경은 Node.js / PostgreSQL / MinIO S3 및 Redis Queue 아키텍처를 준수합니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-container">
|
||||||
|
<div class="mermaid">
|
||||||
|
graph TD
|
||||||
|
Client[Browser Admin Page] -->|REST API Requests| Backend[Express.js Web Server]
|
||||||
|
Client -->|WebSocket Event Channel| SocketServer[Socket.io Server]
|
||||||
|
Backend -->|SQL Query / PG client| DB[(PostgreSQL Database)]
|
||||||
|
Backend -->|Enqueue / Monitor| Redis[(Redis / BullMQ Queue)]
|
||||||
|
SocketServer -->|Active Session Cache| Memory[Node.js Process Memory]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout callout-note">
|
||||||
|
<div class="callout-title">ℹ️ 백엔드 연동 방식</div>
|
||||||
|
<div class="callout-content">
|
||||||
|
본 시스템은 관리 업무의 연속성 확보 및 동시성 처리를 위해 REST API 채널 외에도 Socket.io 서버 채널을 동시에 가용하여 실시간 웹소켓 통신을 처리하도록 구성됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 2 -->
|
||||||
|
<section id="migration">
|
||||||
|
<h2 class="section-title">구현 단계별 개발 로드맵</h2>
|
||||||
|
|
||||||
|
<h3>[1단계] 데이터베이스 스키마 확장 및 마이그레이션</h3>
|
||||||
|
<ul>
|
||||||
|
<li>공통 코드 분류 및 세부 값 제어를 위한 <code>code_master</code> 및 <code>code_detail</code> 테이블 신설.</li>
|
||||||
|
<li><strong>기존 테이블 활용 (컬럼 추가 없음)</strong>: 기존에 존재하는 프로젝트 테이블(<code>tb_project</code>)의 <code>category</code> 컬럼과 사용자 테이블(<code>tb_user</code>)의 <code>"group"</code> 컬럼을 <code>code_detail.base_code</code> 외래키 참조 구조로 그대로 연동하여 불필요한 테이블 팽창 및 컬럼 추가를 차단합니다.</li>
|
||||||
|
<li>시스템 공통 자동 보존 및 삭제 임계치를 독립적으로 제어하기 위한 <strong><code>tb_system_policy</code> (시스템 공통 정책 테이블)</strong> 신설.</li>
|
||||||
|
<li>실시간 배너 공지 이력 보관을 위한 <code>tb_banner_notice</code> 및 정기 자동삭제 실행 결과를 기록하기 위한 <code>tb_auto_clean_log</code> 신설.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>[2단계] 백엔드 RESTful API 및 WebSocket 핸들러 개발</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>REST API</strong>: 각 기능 및 공통 코드 데이터의 CRUD API Endpoint 구축 (Express Controller 연동).</li>
|
||||||
|
<li><strong>WebSocket</strong>: <code>socket.js</code>에 접속한 클라이언트 세션을 <code>users</code> 메모리 맵에 보관 및 관리자가 강제퇴장(<code>forcedLogout</code>) 이벤트를 호출하면 해당 클라이언트 소켓의 접속을 끊고 세션을 제거하도록 구현.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>[3단계] 프론트엔드 어드민 대시보드 UI 연동</h3>
|
||||||
|
<ul>
|
||||||
|
<li>프로토타입 디자인 컨셉(Pretendard 서체, Forest Green 테마 색상)을 CSS 변수로 그대로 반영.</li>
|
||||||
|
<li>모달 다이얼로그와 탭 렌더링에 실시간 API 데이터 통신 로직 바인딩.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 3 -->
|
||||||
|
<section id="schema-design">
|
||||||
|
<h2 class="section-title">2. 데이터베이스 스키마 설계 (ERD)</h2>
|
||||||
|
<p>기존에 이미 보유한 <code>tb_project.category</code> 및 <code>tb_user."group"</code> 구조를 활용한 관계 정의는 다음과 같습니다.</p>
|
||||||
|
|
||||||
|
<div class="diagram-container">
|
||||||
|
<div class="mermaid">
|
||||||
|
classDiagram
|
||||||
|
class code_master {
|
||||||
|
+main_code : VARCHAR(30) PK
|
||||||
|
+main_code_nm : VARCHAR(100)
|
||||||
|
+use_yn : CHAR(1)
|
||||||
|
+rmk : VARCHAR(255)
|
||||||
|
}
|
||||||
|
class code_detail {
|
||||||
|
+main_code : VARCHAR(30) PK/FK
|
||||||
|
+sub_code : VARCHAR(30) PK
|
||||||
|
+base_code : VARCHAR(61) UNIQUE
|
||||||
|
+code_nm : VARCHAR(100)
|
||||||
|
+sort_ord : INT
|
||||||
|
+use_yn : CHAR(1)
|
||||||
|
+rmk : VARCHAR(255)
|
||||||
|
}
|
||||||
|
class tb_project {
|
||||||
|
+project_id : VARCHAR(50) PK
|
||||||
|
+project_nm : VARCHAR(100)
|
||||||
|
+category : VARCHAR(50) FK
|
||||||
|
+limit_storage : INT
|
||||||
|
+is_active : BOOLEAN
|
||||||
|
}
|
||||||
|
class tb_system_policy {
|
||||||
|
+policy_id : INT PK
|
||||||
|
+policy_key : VARCHAR(50) UNIQUE
|
||||||
|
+limit_file_count : INT
|
||||||
|
+limit_days : INT
|
||||||
|
+is_active : BOOLEAN
|
||||||
|
+upd_date : TIMESTAMP
|
||||||
|
}
|
||||||
|
class tb_user {
|
||||||
|
+user_id : VARCHAR(50) PK
|
||||||
|
+user_nm : VARCHAR(50)
|
||||||
|
+user_pw : VARCHAR(255)
|
||||||
|
+company : VARCHAR(50)
|
||||||
|
+dept : VARCHAR(50)
|
||||||
|
+position : VARCHAR(50)
|
||||||
|
+group : VARCHAR(50) FK
|
||||||
|
+is_resigned : BOOLEAN
|
||||||
|
}
|
||||||
|
class tb_permission {
|
||||||
|
+project_id : VARCHAR(50) PK/FK
|
||||||
|
+user_id : VARCHAR(50) PK/FK
|
||||||
|
+lev : INT
|
||||||
|
}
|
||||||
|
class tb_banner_notice {
|
||||||
|
+banner_id : SERIAL PK
|
||||||
|
+project_id : VARCHAR(50) FK
|
||||||
|
+reg_date : DATE
|
||||||
|
+start_date : DATE
|
||||||
|
+end_date : DATE
|
||||||
|
+notice_text : TEXT
|
||||||
|
+status_code : VARCHAR(61) FK
|
||||||
|
}
|
||||||
|
class tb_log {
|
||||||
|
+log_id : SERIAL PK
|
||||||
|
+project_id : VARCHAR(50)
|
||||||
|
+activity : VARCHAR(100)
|
||||||
|
+user_id : VARCHAR(50)
|
||||||
|
+user_ip : VARCHAR(50)
|
||||||
|
+log_date : TIMESTAMP
|
||||||
|
+path_arr : TEXT[]
|
||||||
|
}
|
||||||
|
code_master "1" --> "0..*" code_detail
|
||||||
|
code_detail "1" --> "0..*" tb_project : "category 참조"
|
||||||
|
code_detail "1" --> "0..*" tb_user : "group 참조"
|
||||||
|
code_detail "1" --> "0..*" tb_banner_notice : "status_code 참조"
|
||||||
|
tb_project "1" --> "0..*" tb_permission
|
||||||
|
tb_user "1" --> "0..*" tb_permission
|
||||||
|
tb_project "1" --> "0..*" tb_banner_notice
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 4 -->
|
||||||
|
<section id="screen-dashboard">
|
||||||
|
<h2 class="section-title">3. 화면별 연동 테이블 및 데이터베이스 상세 매핑</h2>
|
||||||
|
|
||||||
|
<h3>📊 화면 1: 종합 용량 및 접속자 현황 (Dashboard)</h3>
|
||||||
|
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>전체 사용 용량 KPI & 현장별 게이지바</strong>:
|
||||||
|
<code>tb_project</code> 테이블의 한도 용량(<code>storage_byte</code>)과 <code>tb_data</code> 테이블의 실 누적 용량(<code>data_size</code>) 합산 결과 및 파일 건수를 <code>COUNT()</code>하여 렌더링.
|
||||||
|
</li>
|
||||||
|
<li><strong>실시간 접속자 세션 목록</strong>:
|
||||||
|
데이터베이스를 경유하지 않고 Node.js 메인 프로세스(<code>socket.js</code>)의 메모리 세션 해시맵에서 직접 연결 상태(ID, IP, 위치 경로)를 추출.
|
||||||
|
</li>
|
||||||
|
<li><strong>대기 중인 압축작업</strong>:
|
||||||
|
Redis(BullMQ Queue) 대기열 API를 조회하여 보류 중인 백그라운드 압축 다운로드 개수 파악.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-project">
|
||||||
|
<h3>🏗️ 화면 2: 프로젝트 관리 (Project Management)</h3>
|
||||||
|
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>좌측 프로젝트 그리드</strong>: <code>tb_project</code> 테이블 전체 데이터와 <code>code_detail</code>의 카테고리 코드 한글 명칭(TDC, GPD 등)을 <code>tb_project.category</code> 외래키 관계를 조인하여 렌더링.</li>
|
||||||
|
<li><strong>우측 참여 권한 사용자 목록</strong>: <code>tb_permission</code> $\bowtie$ <code>tb_user</code> 조인을 통해 해당 현장에 기속된 사용자 리스트업 및 등급 인라인 셀렉트 업데이트.</li>
|
||||||
|
<li><strong>사용자 배정 추가 모달</strong>: <code>tb_user</code>의 전체 사용자 데이터와 현재 프로젝트에 속해 있지 않은 유저 차집합 연산으로 가용 유저 목록 구성.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-banner">
|
||||||
|
<h3>📢 화면 3: 실시간 배너 공지 (Banner Notice)</h3>
|
||||||
|
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>공지사항 이력 및 등록 폼</strong>: <code>tb_banner_notice</code> 테이블 CRUD 매핑.</li>
|
||||||
|
<li><strong>송출 상태 계산</strong>:
|
||||||
|
오늘 날짜 기준 <code>start_date</code>와 <code>end_date</code> 조건을 체크하여 공통코드 매핑 상태(<code>NOTICE_STATUS_active</code>, <code>NOTICE_STATUS_scheduled</code>, <code>NOTICE_STATUS_expired</code>)를 동적 할당.
|
||||||
|
</li>
|
||||||
|
<li><strong>검색 필터</strong>: 송출상태 및 등록일 범위(From ~ To) 기준 <code>WHERE status_code = ? AND reg_date BETWEEN ? AND ?</code> 쿼리 연동.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-user">
|
||||||
|
<h3>👥 화면 4: 사용자 관리 (User Management)</h3>
|
||||||
|
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>좌측 계정 목록</strong>: <code>tb_user</code> 테이블 및 <code>code_detail</code> 권한그룹(기존 <code>"group"</code> 컬럼 재사용) 매핑 렌더링.</li>
|
||||||
|
<li><strong>우측 참여 프로젝트 목록</strong>: <code>tb_permission</code> $\bowtie$ <code>tb_project</code> 조인을 활용하여 선택 유저가 소속된 모든 프로젝트 ID 및 현장명 출력.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-audit">
|
||||||
|
<h3>🔎 화면 5: 감사 로그 조회 (Audit Logs)</h3>
|
||||||
|
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>시스템 감사 이력 목록</strong>:
|
||||||
|
파일 삭제/이동/다운로드 이벤트를 적재하는 <code>tb_log</code> 테이블을 필터 검색 쿼리(사용자 ID, 액션타입)와 연동하여 리스트 렌더링.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-policy">
|
||||||
|
<h3>⚙️ 화면 6: 자동 보존 및 파일 삭제 정책 설정 (Delete Policy)</h3>
|
||||||
|
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>시스템 공통 자동 삭제 정책 설정 폼</strong>:
|
||||||
|
프로젝트 단위 설정을 탈피하고, 신설된 <code>tb_system_policy</code>의 단일 글로벌 레코드를 제어.
|
||||||
|
</li>
|
||||||
|
<li><strong>자동 삭제 정기 실행 이력</strong>:
|
||||||
|
정기 스케줄러 배치 구동 시의 로그 기록을 <code>tb_auto_clean_log</code>에서 리스트업. 설정값 저장 및 기록 적재 시 프로젝트 ID 대신 <code>'SYSTEM'</code> 기록 식별자를 사용.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-codes">
|
||||||
|
<h3>🔑 화면 7: 공통 코드 관리 (Common Code Management)</h3>
|
||||||
|
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>상단 마스터 대분류</strong>: <code>code_master</code> 테이블 CRUD.</li>
|
||||||
|
<li><strong>하단 상세 소분류</strong>: <code>code_detail</code> 테이블 CRUD.
|
||||||
|
<ul>
|
||||||
|
<li>대분류 선택이 없을 시 소분류 등록 폼 진입 차단 유효성 제어.</li>
|
||||||
|
<li>소분류의 <code>base_code</code> 컬럼은 <code>main_code || '_' || sub_code</code> 조합으로 자동 동기화.</li>
|
||||||
|
<li>대분류 삭제 시 외래키 <code>ON DELETE CASCADE</code> 명세를 통하여 소분류 레코드가 데이터베이스에서 연쇄적으로 자동 청소되도록 설정.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 5 -->
|
||||||
|
<section id="api-endpoints">
|
||||||
|
<h2 class="section-title">4. 핵심 백엔드 CRUD API Endpoint 설계안</h2>
|
||||||
|
<p>프론트엔드-백엔드 간 통신을 위해 구성되어야 할 RESTful API 리스트입니다.</p>
|
||||||
|
|
||||||
|
<h3>1. 프로젝트 관리 API (<code>/api/admin/projects</code>)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">메소드</th>
|
||||||
|
<th style="width: 35%;">엔드포인트</th>
|
||||||
|
<th style="width: 50%;">설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/</td>
|
||||||
|
<td>전체 프로젝트 및 카테고리 정보 조회</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-post">POST</span></td>
|
||||||
|
<td class="api-url">/</td>
|
||||||
|
<td>신규 프로젝트 등록</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-put">PUT</span></td>
|
||||||
|
<td class="api-url">/:id</td>
|
||||||
|
<td>특정 프로젝트 내용(카테고리 category 값, 용량제한, 활성화) 갱신</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-delete">DELETE</span></td>
|
||||||
|
<td class="api-url">/:id</td>
|
||||||
|
<td>프로젝트 영구 삭제 (참여 권한 및 메타데이터 자동 CASCADE)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>2. 프로젝트 권한 배정 API (<code>/api/admin/permissions</code>)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">메소드</th>
|
||||||
|
<th style="width: 35%;">엔드포인트</th>
|
||||||
|
<th style="width: 50%;">설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/project/:projectId</td>
|
||||||
|
<td>특정 현장에 참여 중인 유저 목록 조회</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-post">POST</span></td>
|
||||||
|
<td class="api-url">/assign</td>
|
||||||
|
<td>현장에 특정 사용자 권한 신규 부여 (다중 배정)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-put">PUT</span></td>
|
||||||
|
<td class="api-url">/update</td>
|
||||||
|
<td>배정 유저의 권한 등급(lev) 수정</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-delete">DELETE</span></td>
|
||||||
|
<td class="api-url">/remove</td>
|
||||||
|
<td>현장 참여 권한 배정 제외 (매핑 행 삭제)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>3. 실시간 배너 공지 API (<code>/api/admin/banners</code>)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">메소드</th>
|
||||||
|
<th style="width: 35%;">엔드포인트</th>
|
||||||
|
<th style="width: 50%;">설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/</td>
|
||||||
|
<td>배너 송출 이력 조회 (상태, 날짜검색 필터 지원)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-post">POST</span></td>
|
||||||
|
<td class="api-url">/</td>
|
||||||
|
<td>신규 배너 공지 작성 및 등록</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-put">PUT</span></td>
|
||||||
|
<td class="api-url">/stop/:id</td>
|
||||||
|
<td>공지 수동 송출 중지 (상태를 expired로 강제 업데이트)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>4. 사용자 관리 API (<code>/api/admin/users</code>)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">메소드</th>
|
||||||
|
<th style="width: 35%;">엔드포인트</th>
|
||||||
|
<th style="width: 50%;">설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/</td>
|
||||||
|
<td>전체 계정 정보 및 권한그룹 조회</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/:id/permissions</td>
|
||||||
|
<td>해당 유저가 참여 권한을 지닌 프로젝트 목록 조회</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-post">POST</span></td>
|
||||||
|
<td class="api-url">/</td>
|
||||||
|
<td>신규 사용자 계정 등록 (패스워드 bcrypt 암호화)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-put">PUT</span></td>
|
||||||
|
<td class="api-url">/:id</td>
|
||||||
|
<td>사용자 정보(권한 그룹 group 포함) 및 재직 상태 갱신</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-delete">DELETE</span></td>
|
||||||
|
<td class="api-url">/:id</td>
|
||||||
|
<td>사용자 계정 삭제 (권한 정보 및 설정 연쇄 삭제)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>5. 공통 코드 관리 API (<code>/api/admin/common-codes</code>)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">메소드</th>
|
||||||
|
<th style="width: 35%;">엔드포인트</th>
|
||||||
|
<th style="width: 50%;">설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/masters</td>
|
||||||
|
<td>대분류 마스터 코드 목록 조회</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-post">POST</span></td>
|
||||||
|
<td class="api-url">/masters</td>
|
||||||
|
<td>신규 대분류 등록</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-put">PUT</span></td>
|
||||||
|
<td class="api-url">/masters/:code</td>
|
||||||
|
<td>대분류 수정</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-delete">DELETE</span></td>
|
||||||
|
<td class="api-url">/masters/:code</td>
|
||||||
|
<td>대분류 삭제 (하위 소분류 자동 연쇄 삭제)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/details/:mainCode</td>
|
||||||
|
<td>선택된 대분류에 해당하는 소분류 정렬 조회</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-post">POST</span></td>
|
||||||
|
<td class="api-url">/details</td>
|
||||||
|
<td>신규 소분류 등록 (base_code 자동 연산 생성)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-put">PUT</span></td>
|
||||||
|
<td class="api-url">/details/:mainCode/:subCode</td>
|
||||||
|
<td>소분류 수정 (명칭, 정렬순서, 사용여부)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-delete">DELETE</span></td>
|
||||||
|
<td class="api-url">/details/:mainCode/:subCode</td>
|
||||||
|
<td>소분류 삭제</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>6. 시스템 공통 보존 정책 API (<code>/api/admin/system-policy</code>)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%;">메소드</th>
|
||||||
|
<th style="width: 35%;">엔드포인트</th>
|
||||||
|
<th style="width: 50%;">설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-get">GET</span></td>
|
||||||
|
<td class="api-url">/</td>
|
||||||
|
<td>시스템 글로벌 보존 정책 조회</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method-badge method-post">POST</span></td>
|
||||||
|
<td class="api-url">/update</td>
|
||||||
|
<td>글로벌 보존 정책 값 갱신 및 저장</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
586
PM관리자화면개발산출물_20260611/통합관리자대시보드_화면설계서.html
Normal file
586
PM관리자화면개발산출물_20260611/통합관리자대시보드_화면설계서.html
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PM_ver4 통합 관리자 어플리케이션 화면설계서</title>
|
||||||
|
<!-- Google Fonts - Inter & Noto Sans KR -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #1e5149;
|
||||||
|
--primary-dark: #142e29;
|
||||||
|
--primary-soft: #e9eeed;
|
||||||
|
--border: #d2dcdb;
|
||||||
|
--accent: #4db251;
|
||||||
|
--bg: #f4f7f6;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #1f2937;
|
||||||
|
--text-muted: #4b5563;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
|
||||||
|
/* UI Status Colors */
|
||||||
|
--color-active: #4db251;
|
||||||
|
--bg-active: #eef8ee;
|
||||||
|
--color-inactive: #a5b9b6;
|
||||||
|
--bg-inactive: #f1f5f9;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
--bg-danger: #fee2e2;
|
||||||
|
--color-warning: #ff9800;
|
||||||
|
--bg-warning: #fff5e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', 'Noto Sans KR', sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Navigation */
|
||||||
|
aside {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: linear-gradient(180deg, var(--primary-dark) 0%, #0c1a18 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside a {
|
||||||
|
color: #b3c5c2;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside a:hover, aside li.active a {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside .category-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #628781;
|
||||||
|
margin: 16px 0 8px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
main {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 40px 50px;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
padding-bottom: 24px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
header .subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Sections */
|
||||||
|
section {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.section-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color Chips */
|
||||||
|
.color-palette {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-chip {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-box {
|
||||||
|
height: 60px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-details {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-value {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout Preview map */
|
||||||
|
.layout-preview {
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 20px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-active { background-color: var(--bg-active); color: var(--color-active); }
|
||||||
|
.status-badge-inactive { background-color: var(--bg-inactive); color: var(--color-inactive); }
|
||||||
|
.status-badge-danger { background-color: var(--bg-danger); color: var(--color-danger); }
|
||||||
|
.status-badge-warning { background-color: var(--bg-warning); color: var(--color-warning); }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--primary-soft);
|
||||||
|
color: var(--primary-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Sidebar Navigation -->
|
||||||
|
<aside>
|
||||||
|
<h2>📁 PM_ver4 Spec</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#overview">설계서 소개</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="category-title">01. 디자인 시스템</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#design-system">디자인 가이드라인</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="category-title">02. 공통 프레임</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#layout">App Frame 레이아웃</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="category-title">03. 화면별 UI/UX 명세</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#screen-dashboard">📊 종합 용량 및 접속자</a></li>
|
||||||
|
<li><a href="#screen-project">🏗️ 프로젝트 관리</a></li>
|
||||||
|
<li><a href="#screen-banner">📢 실시간 배너 공지</a></li>
|
||||||
|
<li><a href="#screen-user">👥 사용자 관리</a></li>
|
||||||
|
<li><a href="#screen-audit">🔎 감사 로그 조회</a></li>
|
||||||
|
<li><a href="#screen-policy">⚙️ 보관 및 삭제 설정</a></li>
|
||||||
|
<li><a href="#screen-codes">🔑 공통 코드 관리</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main>
|
||||||
|
<header id="overview">
|
||||||
|
<h1>PM_ver4 통합 관리자 어플리케이션 화면설계서</h1>
|
||||||
|
<p class="subtitle">UI/UX Specification - 대시보드 및 관리자 화면 기능 명세서</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Section 1 -->
|
||||||
|
<section id="design-system">
|
||||||
|
<h2 class="section-title">1. 공통 UI 가이드라인 및 디자인 시스템</h2>
|
||||||
|
<p>관리자 패널의 모든 화면 레이아웃, 컴포넌트 명세, 사용자 액션 및 데이터 정합성 검증 규칙은 본 시스템의 디자인 규칙을 준수합니다.</p>
|
||||||
|
|
||||||
|
<h3>① 색상 토큰 (Color Tokens)</h3>
|
||||||
|
<div class="color-palette">
|
||||||
|
<div class="color-chip">
|
||||||
|
<div class="color-box" style="background-color: #1e5149;"></div>
|
||||||
|
<div class="color-details">
|
||||||
|
<div class="color-name">Primary Forest Green</div>
|
||||||
|
<div class="color-value">#1e5149</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-chip">
|
||||||
|
<div class="color-box" style="background-color: #142e29;"></div>
|
||||||
|
<div class="color-details">
|
||||||
|
<div class="color-name">Dark Teal Sidebar</div>
|
||||||
|
<div class="color-value">#142e29</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-chip">
|
||||||
|
<div class="color-box" style="background-color: #d2dcdb;"></div>
|
||||||
|
<div class="color-details">
|
||||||
|
<div class="color-name">Light Green Gray Border</div>
|
||||||
|
<div class="color-value">#d2dcdb</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-chip">
|
||||||
|
<div class="color-box" style="background-color: #e9eeed;"></div>
|
||||||
|
<div class="color-details">
|
||||||
|
<div class="color-name">Soft Accent Green BG</div>
|
||||||
|
<div class="color-value">#e9eeed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>② 서체 (Typography)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>서체 패밀리</strong>: <code>'Pretendard Variable', 'Pretendard', 'Inter', 'Noto Sans KR', sans-serif</code></li>
|
||||||
|
<li><strong>글꼴 크기 명세</strong>:
|
||||||
|
<ul>
|
||||||
|
<li>페이지 메인 타이틀: <code>1.25rem</code> (700 Bold)</li>
|
||||||
|
<li>카드 타이틀: <code>1.05rem</code> (700 Bold)</li>
|
||||||
|
<li>일반 본문 및 테이블 데이터: <code>0.875rem</code> (500 Medium / 600 Semi-Bold)</li>
|
||||||
|
<li>Muted 보조 텍스트: <code>0.8rem</code> / <code>0.75rem</code></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>③ 공통 그리드 & 테이블 (Table Grid Rules)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>순번 표시 (NO)</strong>: 모든 테이블 그리드의 1열은 데이터 인덱스 번호(<code>NO</code>)를 필수 노출합니다.</li>
|
||||||
|
<li><strong>가로 구분선</strong>: 답답한 디자인을 방지하기 위해 세로 테두리(Vertical Borders)는 일절 노출하지 않으며, 가로 행 구분선만 노출합니다.</li>
|
||||||
|
<li><strong>행 선택 인터랙션</strong>: 마우스 호버 시 <code>var(--primary-soft)</code> 배경색을 지정하며, 클릭 행 활성화 시 텍스트 두께 변경으로 인한 줄높이 왜곡이 없도록 패딩 상속(inherit) 속성을 적용하여 균일한 행 높이를 유지합니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>④ 공통 모달 팝업 (Modal Overlay Rules)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>백드롭</strong>: <code>rgba(20, 30, 29, 0.6)</code> 반투명 딤 및 <code>backdrop-filter: blur(4px)</code> 효과로 모달 포커스를 고정합니다.</li>
|
||||||
|
<li><strong>트랜지션</strong>: <code>fade-in</code> 및 <code>slide-up (translateY 30px to 0)</code>이 일괄 적용되어 매끄러운 팝업 동작을 보장합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 2 -->
|
||||||
|
<section id="layout">
|
||||||
|
<h2 class="section-title">2. 레이아웃 구조 설계 (App Frame Layout)</h2>
|
||||||
|
<p>LNB, 상단 헤더, 그리고 메인 콘텐츠 탭 영역의 수평/수직 분할 아키텍처는 다음과 같습니다.</p>
|
||||||
|
|
||||||
|
<div class="layout-preview">
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| LNB (좌측 사이드바) | Main Header (상단 헤더) |
|
||||||
|
| 📁 PM_ver4 Admin | [Header Title] [Admin Profile] |
|
||||||
|
|-------------------------+-----------------------------------------------|
|
||||||
|
| - Dashboards | Main Content (메인 콘텐츠 탭 영역) |
|
||||||
|
| 📊 종합 용량/접속자 | |
|
||||||
|
| - 프로젝트 관리 | +-----------------------------------------+ |
|
||||||
|
| 🏗️ 프로젝트 관리 | | 카드 1 (필터 / 테이블 리스트) | |
|
||||||
|
| 📢 실시간 배너 공지 | +-----------------------------------------+ |
|
||||||
|
| - 사용자 및 권한 | | 카드 2 (상세정보 뷰 / 팝업 연동 리스트) | |
|
||||||
|
| 👥 사용자 관리 | +-----------------------------------------+ |
|
||||||
|
| - 시스템 감사 및 환경 | |
|
||||||
|
| 🔎 감사 로그 조회 | |
|
||||||
|
| ⚙️ 자동 삭제 설정 | |
|
||||||
|
| 🔑 공통 코드 관리 | |
|
||||||
|
+-------------------------------------------------------------------------+</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 3 -->
|
||||||
|
<section id="screen-dashboard">
|
||||||
|
<h2 class="section-title">3. 화면별 상세 UI 및 기능 설계</h2>
|
||||||
|
|
||||||
|
<h3>📊 화면 1: 종합 용량 및 접속자 현황 (Dashboard)</h3>
|
||||||
|
<p>상단 3열 KPI 요약 카드와 하단 스토리지 프로그레스바 및 실시간 소켓 접속자 테이블 구조입니다.</p>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h4>① 주요 UI 컴포넌트</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>스토리지 KPI 카드</strong>: 전체 현장의 총 한도 용량 대비 누적 사용 용량 실시간 합산 표시 (예: <code>💾 9.70 GB / 20 GB</code>).</li>
|
||||||
|
<li><strong>접속자 KPI 카드</strong>: 현재 소켓 서버 연결 세션 수 노출.</li>
|
||||||
|
<li><strong>압축작업 KPI 카드</strong>: Redis(BullMQ) 내 대기중인 압축 건수.</li>
|
||||||
|
<li><strong>현장별 스토리지 사용 현황</strong>: 각 프로젝트 ID/명칭, 게이지바 및 사용량 정보(GB / 백분율% / 파일수량개) 동시 렌더링.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>② 실시간 접속 현황 테이블 사양</h4>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 10%;">NO</th>
|
||||||
|
<th style="width: 25%;">사용자 ID</th>
|
||||||
|
<th style="width: 25%;">접속 IP</th>
|
||||||
|
<th style="width: 25%;">현재 조회 경로</th>
|
||||||
|
<th style="width: 15%;">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>admin_test</td>
|
||||||
|
<td>127.0.0.1</td>
|
||||||
|
<td><code>/PM_TEST_01/archive</code></td>
|
||||||
|
<td><span class="status-badge status-badge-danger" style="cursor: pointer;">강제퇴장</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-project">
|
||||||
|
<h3>🏗️ 화면 2: 프로젝트 관리 (Project Management)</h3>
|
||||||
|
<p>좌측 프로젝트 목록 그리드와 우측 프로젝트별 참여자 및 배정 통제 그리드 구조입니다.</p>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h4>① 프로젝트 목록 테이블 사양 (좌측 카드)</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>프로젝트 ID</code> | <code>현장명</code> | <code>카테고리</code> | <code>용량 제한(GB)</code> | <code>상태</code> | <code>관리(수정/삭제)</code></p>
|
||||||
|
<ul>
|
||||||
|
<li>행 클릭 시, 우측의 '참여 권한 사용자 목록'이 해당 프로젝트 정보로 자동 리바인딩됩니다.</li>
|
||||||
|
<li><strong>삭제 제한</strong>: 관련 테이블(tb_data, tb_official_doc_file, tb_banner_notice 등)에 현장 ID 사용 이력이 있으면 삭제 불가능하며 경고 메시지가 발생합니다.</li>
|
||||||
|
<li><strong>신규 프로젝트 등록 및 수정 모달 (projectModalOverlay)</strong>: 프로젝트 ID(수정 시 Readonly), 프로젝트명, 단축명, 카테고리 Select, 스토리지 제한(GB), 상태를 편집합니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>② 참여 권한 사용자 목록 (우측 카드 - 병합 영역)</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>사용자 ID</code> | <code>이름</code> | <code>부서/직급</code> | <code>권한 등급</code> | <code>작업(배정제외)</code></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>권한 등급 변경</strong>: 인라인 셀렉터(Admin, Sub-Master, Worker, Viewer)로 권한 레벨 즉시 업데이트.</li>
|
||||||
|
<li><strong>사용자 배정 추가 팝업 모달 (assignModalOverlay)</strong>: 현재 현장에 미배정된 사용자들을 체크박스로 다중 선택하여 일괄 추가합니다. 또한 목록 선택식을 지원하기 위해 우측 하단 배정 대기 목록에서도 '즉시 배정' 단축 버튼을 제공합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-banner">
|
||||||
|
<h3>📢 화면 3: 실시간 배너 공지 (Banner Notice)</h3>
|
||||||
|
<p>상단 배너 등록 폼 카드 및 하단 이력 검색 조건 필터와 이력 목록 그리드 구조입니다.</p>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h4>① 배너 공지 등록 폼</h4>
|
||||||
|
<ul>
|
||||||
|
<li>입력 필드: 대상 프로젝트 선택(특정 현장 또는 전체 현장 'all' 매핑), 등록일(임의 편집 지원), 시작일, 종료일, 공지 자막 텍스트.</li>
|
||||||
|
<li>송출 등록 제출 시, 오늘 일자와 비교하여 즉시 이력에 추가되고 상태 배지가 실시간 부여됩니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>② 이력 목록 필터 및 이력 테이블</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>검색 필터</strong>: 송출 상태(전체, 송출중, 예약됨, 만료) 및 등록일(from ~ to) 날짜 범위 지정 검색.</li>
|
||||||
|
<li><strong>테이블 명세</strong>: <code>NO</code> | <code>등록일</code> | <code>대상 프로젝트</code> | <code>공지 내용</code> | <code>시작일</code> | <code>종료일</code> | <code>송출 상태</code> | <code>작업</code></li>
|
||||||
|
<li><strong>송출 중지 통제</strong>: 아직 기간이 유효한 행(송출중, 예약됨)에만 <code>[송출 중지]</code> 버튼이 노출 및 활성화되며, 이미 만료된 이력은 <code>[중지 완료]</code> 비활성 텍스트로 대체하여 이중 제어를 차단합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-user">
|
||||||
|
<h3>👥 화면 4: 사용자 관리 (User Management)</h3>
|
||||||
|
<p>좌측 사용자 마스터 리스트 및 우측 선택된 사용자의 참여 권한 프로젝트 리스트업 구조입니다.</p>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h4>① 사용자 계정 목록 (좌측 카드)</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>아이디</code> | <code>이름</code> | <code>소속/직급</code> | <code>그룹</code> | <code>상태</code> | <code>관리(수정/삭제)</code></p>
|
||||||
|
<ul>
|
||||||
|
<li>행 클릭 시, 해당 사용자가 참여하고 있는 프로젝트 리스트가 우측 카드에 즉시 바인딩됩니다.</li>
|
||||||
|
<li><strong>삭제 제한</strong>: 권한 테이블(tb_permission)에 현장 배정/참여 권한 정보가 등록되어 있으면 삭제가 불가능하며 경고 메시지가 발생합니다.</li>
|
||||||
|
<li><strong>사용자 등록 및 수정 모달 (userModalOverlay)</strong>: 아이디(수정 시 Readonly), 패스워드, 이름, 회사명, 부서, 직급, 권한 그룹 지정 select, 재직 상태(재직/퇴직잠금) 지정.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>② 권한부여 프로젝트 목록 (우측 카드)</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>프로젝트 ID</code> | <code>프로젝트명</code> | <code>부여 권한 등급</code></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-audit">
|
||||||
|
<h3>🔎 화면 5: 감사 로그 조회 (Audit Logs)</h3>
|
||||||
|
<p>파일 조작 중요 이벤트(삭제, 이동, 다운로드) 목록 및 검색 조회 화면입니다.</p>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h4>① 감사 로그 목록 사양</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>일시</code> | <code>프로젝트</code> | <code>사용자 ID</code> | <code>접속 IP</code> | <code>조작 액션</code> | <code>조작 대상 경로(코드박스 스타일)</code></p>
|
||||||
|
<ul>
|
||||||
|
<li>필터링 항목: 사용자 ID 검색 입력란, 조작 액션 Dropdown, 검색 기능.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-policy">
|
||||||
|
<h3>⚙️ 화면 6: 자동 보관 및 파일 삭제 정책 설정 (Delete Policy)</h3>
|
||||||
|
<p>시스템 글로벌 일괄 정책 설정 영역, 실시간 예정 시나리오 요약, 그리고 배치 처리 이력 구조입니다.</p>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h4>① 시스템 공통 자동 삭제 정책 설정 폼 [글로벌 정책 공통화]</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>입력 필드</strong>: 정책 활성화 여부(Toggle/Select), 최소 유지 파일 개수 기준(숫자 입력), 자동 삭제 제한 기한(일) (숫자 입력).</li>
|
||||||
|
<li><strong>글로벌 통합</strong>: 기존의 프로젝트 개별 Dropdown은 완전히 배제하고, 전체 현장에 동일하게 일괄 반영합니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>② 보존 정책 실시간 요약 (Dynamic Summary)</h4>
|
||||||
|
<ul>
|
||||||
|
<li>폼의 입력값을 변경하는 즉시 요약 영역 텍스트가 시나리오 문구로 동적 조합되어 나타납니다.</li>
|
||||||
|
<li>예: <code>"현재 전체 공통 설정에 따라, 각 현장의 보관 파일 수가 100개 미만이고 30일이 지나면 자동 삭제 배치 스케줄러가 작동합니다."</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>③ 자동 삭제 처리 이력 테이블 사양</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>자동 처리 일자</code> | <code>프로젝트 ID</code> | <code>삭제 처리 폴더 경로</code> | <code>적용 기준</code> | <code>처리 결과(성공 배지)</code></p>
|
||||||
|
<ul>
|
||||||
|
<li>정책 값 저장 완료 시 이력 로그의 대상 프로젝트 ID 자리에는 <code>'SYSTEM'</code>이 기입됩니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-codes">
|
||||||
|
<h3>🔑 화면 7: 공통 코드 관리 (Common Code Management)</h3>
|
||||||
|
<p>대분류 마스터 및 세부 코드 리스트가 배치되는 상하 2단 수직 정렬 레이아웃 구조입니다.</p>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h4>① 대분류 코드 마스터 (상단 카드)</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>대분류 코드</code> | <code>대분류 코드명</code> | <code>사용</code> | <code>관리(수정/삭제)</code></p>
|
||||||
|
<ul>
|
||||||
|
<li>행을 선택(click)하면 해당 행이 하이라이트(selected)되며, 하단의 세부 코드 그리드가 동적으로 새로고침됩니다.</li>
|
||||||
|
<li><strong>대분류 등록 및 수정 모달 (codeMasterModalOverlay)</strong>: 대분류 코드, 명칭, 사용여부, 비고 설명 입력.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>② 세부 소분류 코드 목록 (하단 카드)</h4>
|
||||||
|
<p>출력 컬럼: <code>NO</code> | <code>소분류 코드</code> | <code>조합 코드 (base_code)</code> | <code>코드 명칭</code> | <code>정렬 순서</code> | <code>사용</code> | <code>관리(수정/삭제)</code></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>유효성 방어 차단</strong>: 상단 대분류 테이블에서 행을 클릭하여 선택하지 않은 상태에서는 하단의 <code>[➕ 세부코드 등록]</code> 버튼이 강제 비활성화(disabled)되며, 팝업 접근 시 안내 팝업 및 경고 텍스트(<code>"상단에서 대분류 코드를 선택해 주세요."</code>)를 노출합니다.</li>
|
||||||
|
<li><strong>조합 코드(base_code)</strong>: 소분류 생성 완료 제출 시, <code>대분류코드_소분류코드</code> 형태로 자동 결합되어 저장됩니다.</li>
|
||||||
|
<li><strong>삭제 제한 (RESTRICT)</strong>: 마스터 대분류 코드를 삭제할 경우, 하위 세부 코드(code_detail)가 존재하면 대분류 삭제가 차단되고 경고 메시지를 노출합니다. (세부 코드가 먼저 삭제되어 비어있을 때만 대분류 삭제 가능)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -615,34 +615,63 @@ exports.deleteUser = async (req, res) => {
|
|||||||
|
|
||||||
// 5. 활동 로그 조회 (Activity Logs)
|
// 5. 활동 로그 조회 (Activity Logs)
|
||||||
exports.getAuditLogs = async (req, res) => {
|
exports.getAuditLogs = async (req, res) => {
|
||||||
const { user_id, activity, project_nm } = req.query;
|
let { user_id, activity, project_nm, page = 1, limit = 20 } = req.query;
|
||||||
|
page = parseInt(page);
|
||||||
|
limit = parseInt(limit);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
let countQuery = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM ver4.${tbLog} l
|
||||||
|
LEFT JOIN ver4.${tbProject} p ON l.project_id = p.project_id
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT l.log_id, l.log_date as clean_date, l.project_id, p.project_nm, l.user_id, l.user_ip, l.activity as clean_path, l.path_arr as criteria_info
|
SELECT l.log_id, l.log_date as clean_date, l.project_id, p.project_nm, l.user_id, l.user_ip, l.activity as clean_path, l.path_arr as criteria_info
|
||||||
FROM ver4.${tbLog} l
|
FROM ver4.${tbLog} l
|
||||||
LEFT JOIN ver4.${tbProject} p ON l.project_id = p.project_id
|
LEFT JOIN ver4.${tbProject} p ON l.project_id = p.project_id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const params = [];
|
const filterParams = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
let filterSql = '';
|
||||||
|
|
||||||
if (user_id) {
|
if (user_id) {
|
||||||
query += ` AND l.user_id ILIKE $${paramIndex++}`;
|
filterSql += ` AND l.user_id ILIKE $${paramIndex++}`;
|
||||||
params.push(`%${user_id}%`);
|
filterParams.push(`%${user_id}%`);
|
||||||
}
|
}
|
||||||
if (project_nm) {
|
if (project_nm) {
|
||||||
query += ` AND (p.project_nm ILIKE $${paramIndex++} OR l.project_id ILIKE $${paramIndex - 1})`;
|
filterSql += ` AND (p.project_nm ILIKE $${paramIndex++} OR l.project_id ILIKE $${paramIndex - 1})`;
|
||||||
params.push(`%${project_nm}%`);
|
filterParams.push(`%${project_nm}%`);
|
||||||
}
|
}
|
||||||
if (activity && activity !== 'all') {
|
if (activity && activity !== 'all') {
|
||||||
query += ` AND l.activity ILIKE $${paramIndex++}`;
|
filterSql += ` AND l.activity ILIKE $${paramIndex++}`;
|
||||||
params.push(`%${activity}%`);
|
filterParams.push(`%${activity}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
query += ` ORDER BY l.log_id DESC LIMIT 100;`;
|
countQuery += filterSql;
|
||||||
const result = await client.query(query, params);
|
query += filterSql;
|
||||||
res.status(200).json(result.rows);
|
|
||||||
|
const countResult = await client.query(countQuery, filterParams);
|
||||||
|
const total = parseInt(countResult.rows[0].total);
|
||||||
|
|
||||||
|
query += ` ORDER BY l.log_id DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++};`;
|
||||||
|
const dataParams = [...filterParams, limit, offset];
|
||||||
|
|
||||||
|
const result = await client.query(query, dataParams);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("getAuditLogs Error:", err);
|
console.error("getAuditLogs Error:", err);
|
||||||
res.status(500).json({ error: "활동 로그 조회 실패" });
|
res.status(500).json({ error: "활동 로그 조회 실패" });
|
||||||
|
|||||||
@@ -795,148 +795,15 @@ async function insertData(params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function insertLog(params, from) {
|
async function insertLog(params, from) {
|
||||||
let { projectId, activity, userInfoString, userIp, resourcePathArr, dataIdArr, isExpiredFolder } = params;
|
// Synchronous database inserts for activity log are now handled asynchronously
|
||||||
|
// by the activityLogger middleware via BullMQ.
|
||||||
if (from) {
|
return { message: 'insertLog_success' };
|
||||||
console.log();
|
|
||||||
console.log(`=======================================`);
|
|
||||||
console.log(`${from}에서 insertLog 실행 (${makePostgresTimestamp()})`);
|
|
||||||
console.log(userInfoString);
|
|
||||||
console.log('isExpiredFolder: ', isExpiredFolder);
|
|
||||||
console.log(`=======================================`);
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity == 'removeTarget_folder' && isExpiredFolder) activity = `${activity}_expired`
|
|
||||||
|
|
||||||
let userInfo = JSON.parse(userInfoString);
|
|
||||||
let userId = userInfo.user_id;
|
|
||||||
|
|
||||||
let dateNow = makePostgresTimestamp(Date.now());
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
let parsedResourcePathArr = resourcePathArr;
|
|
||||||
if (typeof resourcePathArr === 'string') {
|
|
||||||
try {
|
|
||||||
parsedResourcePathArr = JSON.parse(resourcePathArr);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(parsedResourcePathArr)) {
|
|
||||||
parsedResourcePathArr = [parsedResourcePathArr];
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedDataIdArr = dataIdArr;
|
|
||||||
if (typeof dataIdArr === 'string') {
|
|
||||||
try {
|
|
||||||
parsedDataIdArr = JSON.parse(dataIdArr);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(parsedDataIdArr)) {
|
|
||||||
parsedDataIdArr = [parsedDataIdArr];
|
|
||||||
}
|
|
||||||
|
|
||||||
let values = [
|
|
||||||
projectId,
|
|
||||||
activity,
|
|
||||||
userId,
|
|
||||||
userIp,
|
|
||||||
dateNow,
|
|
||||||
parsedResourcePathArr,
|
|
||||||
parsedDataIdArr
|
|
||||||
];
|
|
||||||
|
|
||||||
let queryString = `
|
|
||||||
INSERT INTO ver4.${tbLog} (
|
|
||||||
project_id, activity, user_id, user_ip, log_date, path_arr, data_id_arr
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// console.log('=================');
|
|
||||||
// console.log(queryString);
|
|
||||||
|
|
||||||
await client.query(queryString, values);
|
|
||||||
|
|
||||||
let result = { message: 'insertLog_success' };
|
|
||||||
return result;
|
|
||||||
} catch(error) {
|
|
||||||
console.error("insertLog err:", error);
|
|
||||||
return { message: 'insertLog_failed', error: error };
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
// console.log('============================');
|
|
||||||
// console.log(projectId);
|
|
||||||
// console.log(activity);
|
|
||||||
// console.log(userId);
|
|
||||||
// console.log(userIp);
|
|
||||||
// console.log(resourcePathArr);
|
|
||||||
// console.log(dataIdArr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertClickLog(params) {
|
async function insertClickLog(params) {
|
||||||
let { projectId, activity, userInfoString, userIp, resourcePathArr, dataIdArr } = params;
|
// Synchronous database inserts for click log are now handled asynchronously
|
||||||
let userInfo = JSON.parse(userInfoString);
|
// by the activityLogger middleware via BullMQ.
|
||||||
let userId = userInfo.user_id;
|
return { message: 'insertClickLog_success' };
|
||||||
|
|
||||||
let dateNow = makePostgresTimestamp(Date.now());
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
let parsedResourcePathArr = resourcePathArr;
|
|
||||||
if (typeof resourcePathArr === 'string') {
|
|
||||||
try {
|
|
||||||
parsedResourcePathArr = JSON.parse(resourcePathArr);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(parsedResourcePathArr)) {
|
|
||||||
parsedResourcePathArr = [parsedResourcePathArr];
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedDataIdArr = dataIdArr;
|
|
||||||
if (typeof dataIdArr === 'string') {
|
|
||||||
try {
|
|
||||||
parsedDataIdArr = JSON.parse(dataIdArr);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(parsedDataIdArr)) {
|
|
||||||
parsedDataIdArr = [parsedDataIdArr];
|
|
||||||
}
|
|
||||||
|
|
||||||
let values = [
|
|
||||||
projectId,
|
|
||||||
activity,
|
|
||||||
userId,
|
|
||||||
userIp,
|
|
||||||
dateNow,
|
|
||||||
parsedResourcePathArr,
|
|
||||||
parsedDataIdArr
|
|
||||||
];
|
|
||||||
|
|
||||||
let queryString = `
|
|
||||||
INSERT INTO ver4.${tbClickLog} (
|
|
||||||
project_id, activity, user_id, user_ip, log_date, path_arr, data_id_arr
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// console.log('=================');
|
|
||||||
// console.log(queryString);
|
|
||||||
|
|
||||||
await client.query(queryString, values);
|
|
||||||
|
|
||||||
let result = { message: 'insertClickLog_success' };
|
|
||||||
return result;
|
|
||||||
} catch(error) {
|
|
||||||
console.error("insertClickLog err:", error);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const convertQueue3 = new Queue('zip-folder', { connection: redisConnection });
|
|||||||
const convertQueue4 = new Queue('post-process-video', { connection: redisConnection });
|
const convertQueue4 = new Queue('post-process-video', { connection: redisConnection });
|
||||||
const convertQueue5 = new Queue('ai-summarize', { connection: redisConnection });
|
const convertQueue5 = new Queue('ai-summarize', { connection: redisConnection });
|
||||||
const convertQueue6 = new Queue('api-summarize', {connection: redisConnection});
|
const convertQueue6 = new Queue('api-summarize', {connection: redisConnection});
|
||||||
|
const convertQueue7 = new Queue('activity-log', {connection: redisConnection});
|
||||||
// const convertQueue3 = new Queue('test-job', { connection: redisConnection });
|
// const convertQueue3 = new Queue('test-job', { connection: redisConnection });
|
||||||
|
|
||||||
// 2) ExpressAdapter 생성 및 기본 경로 설정
|
// 2) ExpressAdapter 생성 및 기본 경로 설정
|
||||||
@@ -28,6 +29,7 @@ createBullBoard({
|
|||||||
new BullMQAdapter(convertQueue4),
|
new BullMQAdapter(convertQueue4),
|
||||||
new BullMQAdapter(convertQueue5),
|
new BullMQAdapter(convertQueue5),
|
||||||
new BullMQAdapter(convertQueue6),
|
new BullMQAdapter(convertQueue6),
|
||||||
|
new BullMQAdapter(convertQueue7),
|
||||||
// new BullMQAdapter(convertQueue3),
|
// new BullMQAdapter(convertQueue3),
|
||||||
// 여기에 더 필요한 큐 어댑터를 추가할 수 있습니다.
|
// 여기에 더 필요한 큐 어댑터를 추가할 수 있습니다.
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ exports.getProject = async (req,res,next)=>{
|
|||||||
try {
|
try {
|
||||||
// 테스트
|
// 테스트
|
||||||
let queryString = `
|
let queryString = `
|
||||||
select p.*, u.*
|
select p.*, u.*,
|
||||||
|
(SELECT notice_text
|
||||||
|
FROM ver4.tb_banner_notice b
|
||||||
|
WHERE (b.project_id = p.project_id OR b.project_id IS NULL)
|
||||||
|
AND b.status_code = 'NOTICE_STATUS_active'
|
||||||
|
AND CURRENT_DATE BETWEEN b.start_date AND b.end_date
|
||||||
|
ORDER BY b.banner_id DESC
|
||||||
|
LIMIT 1) as banner_notice
|
||||||
from ver4.${tbProject} p
|
from ver4.${tbProject} p
|
||||||
inner join ver4.tb_user u
|
inner join ver4.tb_user u
|
||||||
on p.user_id = u.user_id
|
on p.user_id = u.user_id
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const pool = require("./db/pool.js");
|
const pool = require("./db/pool.js");
|
||||||
|
|
||||||
async function runPatch() {
|
async function runPatch() {
|
||||||
|
const env = process.env.NODE_ENV;
|
||||||
|
const tbProject = env === 'production' ? 'tb_project' : '_test_tb_project';
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
console.log("🚀 Starting Admin Dashboard DB Patch...");
|
console.log("🚀 Starting Admin Dashboard DB Patch...");
|
||||||
@@ -49,7 +51,7 @@ async function runPatch() {
|
|||||||
await client.query(`
|
await client.query(`
|
||||||
CREATE TABLE IF NOT EXISTS ver4.tb_banner_notice (
|
CREATE TABLE IF NOT EXISTS ver4.tb_banner_notice (
|
||||||
banner_id SERIAL PRIMARY KEY,
|
banner_id SERIAL PRIMARY KEY,
|
||||||
project_id VARCHAR(50) REFERENCES ver4.tb_project(project_id),
|
project_id VARCHAR(50),
|
||||||
reg_date DATE DEFAULT CURRENT_DATE,
|
reg_date DATE DEFAULT CURRENT_DATE,
|
||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE NOT NULL,
|
end_date DATE NOT NULL,
|
||||||
@@ -58,6 +60,18 @@ async function runPatch() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 외래키 제약조건을 환경(env)에 맞춰 동적으로 설정 (기존 제약조건이 다르게 걸려있으면 재설정)
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE ver4.tb_banner_notice
|
||||||
|
DROP CONSTRAINT IF EXISTS tb_banner_notice_project_id_fkey
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE ver4.tb_banner_notice
|
||||||
|
ADD CONSTRAINT tb_banner_notice_project_id_fkey
|
||||||
|
FOREIGN KEY (project_id) REFERENCES ver4.${tbProject}(project_id) ON DELETE CASCADE
|
||||||
|
`);
|
||||||
|
|
||||||
// 5. tb_auto_clean_log 테이블 생성
|
// 5. tb_auto_clean_log 테이블 생성
|
||||||
console.log("Creating ver4.tb_auto_clean_log table...");
|
console.log("Creating ver4.tb_auto_clean_log table...");
|
||||||
await client.query(`
|
await client.query(`
|
||||||
|
|||||||
43
db_patch_v4_log.js
Normal file
43
db_patch_v4_log.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const pool = require("./db/pool.js");
|
||||||
|
|
||||||
|
async function runPatch() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
console.log("🚀 Starting DB Patch for Activity Log (tb_log)...");
|
||||||
|
|
||||||
|
// 1. tb_log 테이블 컬럼 추가
|
||||||
|
console.log("Adding columns to ver4.tb_log...");
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE ver4.tb_log
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'SUCCESS',
|
||||||
|
ADD COLUMN IF NOT EXISTS meta_data JSONB;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. _test_tb_log 테이블 컬럼 추가
|
||||||
|
console.log("Adding columns to ver4._test_tb_log...");
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE ver4._test_tb_log
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'SUCCESS',
|
||||||
|
ADD COLUMN IF NOT EXISTS meta_data JSONB;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. 인덱스 최적화
|
||||||
|
console.log("Creating indexes for activity log tables...");
|
||||||
|
await client.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tb_log_project_date ON ver4.tb_log (project_id, log_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tb_log_user_date ON ver4.tb_log (user_id, log_date DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_test_tb_log_project_date ON ver4._test_tb_log (project_id, log_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_test_tb_log_user_date ON ver4._test_tb_log (user_id, log_date DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log("🎉 DB Patch for Activity Log completed successfully!");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ DB Patch Error:", err);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runPatch();
|
||||||
BIN
downloaded_test.hwpx
Normal file
BIN
downloaded_test.hwpx
Normal file
Binary file not shown.
BIN
libs/hwp.js
BIN
libs/hwp.js
Binary file not shown.
2
libs/wmf.js
Normal file
2
libs/wmf.js
Normal file
File diff suppressed because one or more lines are too long
@@ -39,6 +39,11 @@
|
|||||||
"date": 1781740205874,
|
"date": 1781740205874,
|
||||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.exception.log",
|
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.exception.log",
|
||||||
"hash": "b6c375bb37b791ea2408e99580d68d5582d2e051ea204bc0529f30d0d3452da6"
|
"hash": "b6c375bb37b791ea2408e99580d68d5582d2e051ea204bc0529f30d0d3452da6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1781826376885,
|
||||||
|
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-19.exception.log",
|
||||||
|
"hash": "9dc3f5a0ca6cb76f233f9ffa9a7c095d01dc03bb7295be9b7bca9f9cdb1ca191"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hashType": "sha256"
|
"hashType": "sha256"
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
"date": 1781740205863,
|
"date": 1781740205863,
|
||||||
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.error.log",
|
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.error.log",
|
||||||
"hash": "c3ea07a16e0988461aee9271b7f5b0faabeadbf8df4f36e428e1e43c313c7738"
|
"hash": "c3ea07a16e0988461aee9271b7f5b0faabeadbf8df4f36e428e1e43c313c7738"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1781826376883,
|
||||||
|
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-19.error.log",
|
||||||
|
"hash": "31106fea524e9fea9db56be5b73bd9317b65d7a031a7d6dcf9586a6d9376ce38"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hashType": "sha256"
|
"hashType": "sha256"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
2026-06-18 15:34:14 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'getWaitingCount')
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'getWaitingCount')
|
||||||
|
at exports.convertPdf (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\controllers\archiveController.js:3144:46)
|
||||||
|
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||||
|
|||||||
11
logs/2026-06-19.error.log
Normal file
11
logs/2026-06-19.error.log
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
2026-06-19 09:08:38 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:08:41 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:08:44 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:08:46 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:08:52 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:08:54 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:09:02 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:09:05 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:09:16 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:09:17 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
|
2026-06-19 09:09:19 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
|
||||||
4
logs/2026-06-19.exception.log
Normal file
4
logs/2026-06-19.exception.log
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
2026-06-19 09:25:33 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'getWaitingCount')
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'getWaitingCount')
|
||||||
|
at exports.convertPdf (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\controllers\archiveController.js:3144:46)
|
||||||
|
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||||
70
middlewares/activityLogger.js
Normal file
70
middlewares/activityLogger.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const { addActivityLogJob } = require('../queue');
|
||||||
|
|
||||||
|
exports.activityLogger = (defaultAction) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
try {
|
||||||
|
const params = req.body?.params || {};
|
||||||
|
|
||||||
|
const projectId = params.projectId || req.baseUrl.split('/')[1] || '-';
|
||||||
|
const activity = params.activity || defaultAction || '-';
|
||||||
|
|
||||||
|
let userId = 'system';
|
||||||
|
if (params.userInfoString) {
|
||||||
|
try {
|
||||||
|
const userInfo = JSON.parse(params.userInfoString);
|
||||||
|
userId = userInfo.user_id || 'system';
|
||||||
|
} catch (e) {}
|
||||||
|
} else if (req.user?.user_id) {
|
||||||
|
userId = req.user.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIp = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
let targetPath = '-';
|
||||||
|
if (params.resourcePath) {
|
||||||
|
targetPath = params.resourcePath;
|
||||||
|
} else if (Array.isArray(params.resourcePathArr) && params.resourcePathArr.length > 0) {
|
||||||
|
targetPath = params.resourcePathArr[0];
|
||||||
|
} else if (Array.isArray(params.fromPathArr) && params.fromPathArr.length > 0) {
|
||||||
|
targetPath = params.fromPathArr[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataId = null;
|
||||||
|
if (params.dataId) {
|
||||||
|
dataId = params.dataId;
|
||||||
|
} else if (Array.isArray(params.dataIdArr) && params.dataIdArr.length > 0) {
|
||||||
|
dataId = params.dataIdArr[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logData = {
|
||||||
|
projectId,
|
||||||
|
activity,
|
||||||
|
userId,
|
||||||
|
userIp,
|
||||||
|
targetPath,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
logDate: new Date(),
|
||||||
|
metaData: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
dataIdArr: params.dataIdArr || (dataId ? [dataId] : []),
|
||||||
|
dataType: params.dataType || '-',
|
||||||
|
newName: params.newName || '-',
|
||||||
|
oldName: params.oldName || '-'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addActivityLogJob(logData).catch(err => {
|
||||||
|
console.error("[ActivityLogger Middleware] Queue Error:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ActivityLogger Middleware] Intercept Error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
75
queue.js
75
queue.js
@@ -2,7 +2,6 @@ const { Queue, QueueEvents, Job, Worker } = require('bullmq');
|
|||||||
const { redisConnection } = require('./config/redis.js');
|
const { redisConnection } = require('./config/redis.js');
|
||||||
const { getIo } = require('./socket');
|
const { getIo } = require('./socket');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const controllers = require(`./controllers/archiveController.js`);
|
|
||||||
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
||||||
const pool = require('./db/pool.js');
|
const pool = require('./db/pool.js');
|
||||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
@@ -182,7 +181,7 @@ summarizeAIQueueEvents.on('completed', async({ jobId, returnvalue }) => {
|
|||||||
type: type
|
type: type
|
||||||
};
|
};
|
||||||
|
|
||||||
await controllers.addSummarizeAiLog(addSummarizeAiLogParams);
|
await require('./controllers/archiveController.js').addSummarizeAiLog(addSummarizeAiLogParams);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,7 +265,7 @@ summarizeAPIQueueEvents.on('completed', async({ jobId, returnvalue }) => {
|
|||||||
isState: true
|
isState: true
|
||||||
};
|
};
|
||||||
|
|
||||||
await controllers.addSummarizeAiLog(addSummarizeAiLogParams);
|
await require('./controllers/archiveController.js').addSummarizeAiLog(addSummarizeAiLogParams);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -515,5 +514,73 @@ const summarizeAPIWorker = new Worker('api-summarize', async (job) => {
|
|||||||
lockDuration: 90000 // lock 유지 시간을 90초로 늘려 stalled 에러 방지
|
lockDuration: 90000 // lock 유지 시간을 90초로 늘려 stalled 에러 방지
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { convertPdfQueue, zipFolderQueue, thumbQueue, postProcessVideoQueue, summarizeAIQueue, summarizeAPIQueue };
|
// 🔻🔻🔻🔻🔻🔻🔻🔻 worker7 활동로그 비동기 적재 관련 내용 시작 🔻🔻🔻🔻🔻🔻🔻🔻
|
||||||
|
|
||||||
|
const activityLogQueue = new Queue('activity-log', { connection: redisConnection });
|
||||||
|
|
||||||
|
function makePostgresTimestamp(date) {
|
||||||
|
if (date) date = new Date(date);
|
||||||
|
else date = new Date(Date.now());
|
||||||
|
|
||||||
|
// Intl API로 현재 시스템 타임존 확인
|
||||||
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// KST 변환이 필요한 경우에만 UTC + 9시간 적용
|
||||||
|
const isUtcOrNonSeoul = !timeZone || timeZone !== 'Asia/Seoul';
|
||||||
|
date = isUtcOrNonSeoul ? new Date(date.getTime() + 9 * 60 * 60 * 1000) : date;
|
||||||
|
|
||||||
|
let YYYY = date.getFullYear();
|
||||||
|
let MM = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
let DD = String(date.getDate()).padStart(2, '0');
|
||||||
|
let HH = String(date.getHours()).padStart(2, '0');
|
||||||
|
let mm = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
let ss = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
let SSS = String(date.getMilliseconds()).padStart(3, '0');
|
||||||
|
|
||||||
|
return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}.${SSS}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addActivityLogJob(logData) {
|
||||||
|
await activityLogQueue.add('insert-log', logData, {
|
||||||
|
removeOnComplete: 100, // 완료된 작업 보관 개수 제한
|
||||||
|
attempts: 3, // DB 지연 시 최대 3회 재시도
|
||||||
|
backoff: 5000 // 재시도 간격 5초
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityLogWorker = new Worker('activity-log', async (job) => {
|
||||||
|
const { projectId, activity, userId, userIp, targetPath, status, logDate, metaData } = job.data;
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const env = process.env.NODE_ENV;
|
||||||
|
const tbLog = env === 'production' ? 'tb_log' : '_test_tb_log';
|
||||||
|
const formattedLogDate = makePostgresTimestamp(logDate);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO ver4.${tbLog} (project_id, activity, user_id, user_ip, log_date, path_arr, status, meta_data)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
`, [projectId, activity, userId, userIp, formattedLogDate, [targetPath], status, JSON.stringify(metaData)]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to write activity log to DB:", err);
|
||||||
|
throw err; // triggers BullMQ retry logic
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
connection: redisConnection,
|
||||||
|
lockDuration: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔺🔺🔺🔺🔺🔺🔺🔺 worker7 활동로그 관련 내용 끝 🔺🔺🔺🔺🔺🔺🔺🔺
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
convertPdfQueue,
|
||||||
|
zipFolderQueue,
|
||||||
|
thumbQueue,
|
||||||
|
postProcessVideoQueue,
|
||||||
|
summarizeAIQueue,
|
||||||
|
summarizeAPIQueue,
|
||||||
|
activityLogQueue,
|
||||||
|
addActivityLogJob
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router({mergeParams:true});
|
const router = express.Router({mergeParams:true});
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { isLoggedIn } = require('../oauth/oauthController');
|
const { isLoggedIn } = require('../oauth/oauthController');
|
||||||
|
const { activityLogger } = require('../middlewares/activityLogger');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
// setUseEncrypt,
|
// setUseEncrypt,
|
||||||
@@ -77,22 +78,22 @@ router.get('/getRecycleBinObject', getRecycleBinObject);
|
|||||||
router.get('/getFolderSize', getFolderSize);
|
router.get('/getFolderSize', getFolderSize);
|
||||||
router.get('/getLog', getLog);
|
router.get('/getLog', getLog);
|
||||||
router.post('/checkTargetExists', checkTargetExists);
|
router.post('/checkTargetExists', checkTargetExists);
|
||||||
router.post('/createFolder', createFolder);
|
router.post('/createFolder', activityLogger('createFolder'), createFolder);
|
||||||
router.post('/generateUploadUrl', generateUploadUrl);
|
router.post('/generateUploadUrl', generateUploadUrl);
|
||||||
router.post('/uploadData', uploadData);
|
router.post('/uploadData', activityLogger('uploadData'), uploadData);
|
||||||
router.post('/ensureAddOnFolder', ensureAddOnFolder);
|
router.post('/ensureAddOnFolder', ensureAddOnFolder);
|
||||||
router.post('/renameTarget', renameTarget);
|
router.post('/renameTarget', activityLogger('renameTarget'), renameTarget);
|
||||||
router.post('/editAuthor', editAuthor);
|
router.post('/editAuthor', editAuthor);
|
||||||
router.post('/getDataInfo', getDataInfo);
|
router.post('/getDataInfo', getDataInfo);
|
||||||
router.post('/generateDownloadUrl', generateDownloadUrl);
|
router.post('/generateDownloadUrl', generateDownloadUrl);
|
||||||
router.post('/downloadTarget', downloadTarget);
|
router.post('/downloadTarget', activityLogger('downloadTarget'), downloadTarget);
|
||||||
router.post('/relocateTarget', relocateTarget);
|
router.post('/relocateTarget', activityLogger('relocateTarget'), relocateTarget);
|
||||||
router.post('/removeTarget', removeTarget);
|
router.post('/removeTarget', activityLogger('removeTarget'), removeTarget);
|
||||||
router.post('/deleteTarget', deleteTarget);
|
router.post('/deleteTarget', activityLogger('deleteTarget'), deleteTarget);
|
||||||
router.post('/setDataPermission', setDataPermission);
|
router.post('/setDataPermission', setDataPermission);
|
||||||
router.post('/editPosition', editPosition);
|
router.post('/editPosition', editPosition);
|
||||||
router.post('/renewExpiryDate', renewExpiryDate);
|
router.post('/renewExpiryDate', renewExpiryDate);
|
||||||
router.post('/convertPdf', convertPdf);
|
router.post('/convertPdf', activityLogger('convertPdf'), convertPdf);
|
||||||
router.post('/makeThumbPdf', makeThumbPdf);
|
router.post('/makeThumbPdf', makeThumbPdf);
|
||||||
router.post('/addConvetPdfLog', addConvetPdfLog);
|
router.post('/addConvetPdfLog', addConvetPdfLog);
|
||||||
router.post('/removeConvertingData', removeConvertingData);
|
router.post('/removeConvertingData', removeConvertingData);
|
||||||
@@ -100,24 +101,24 @@ router.post('/postProcessVideo', postProcessVideo);
|
|||||||
router.post('/requestResetViewer', requestResetViewer);
|
router.post('/requestResetViewer', requestResetViewer);
|
||||||
router.post('/updateMemoInfo', updateMemoInfo);
|
router.post('/updateMemoInfo', updateMemoInfo);
|
||||||
router.get('/getMemoInfo', getMemoInfo);
|
router.get('/getMemoInfo', getMemoInfo);
|
||||||
router.post('/uploadData_titleImg', uploadData_titleImg);
|
router.post('/uploadData_titleImg', activityLogger('uploadData_titleImg'), uploadData_titleImg);
|
||||||
router.get('/generateImageUrl', generateImageUrl);
|
router.get('/generateImageUrl', generateImageUrl);
|
||||||
router.get('/isMainTitleImage', isMainTitleImage);
|
router.get('/isMainTitleImage', isMainTitleImage);
|
||||||
router.post('/deleteMainTitleImage', deleteMainTitleImage);
|
router.post('/deleteMainTitleImage', deleteMainTitleImage);
|
||||||
router.post('/mgmtFunc_resetConvert', mgmtFunc_resetConvert);
|
router.post('/mgmtFunc_resetConvert', mgmtFunc_resetConvert);
|
||||||
router.post('/mgmtFunc_addClickLog', mgmtFunc_addClickLog);
|
router.post('/mgmtFunc_addClickLog', activityLogger('clickTarget'), mgmtFunc_addClickLog);
|
||||||
router.get('/getControlBoxPosition', getControlBoxPosition);
|
router.get('/getControlBoxPosition', getControlBoxPosition);
|
||||||
router.post('/setControlBoxPosition', setControlBoxPosition);
|
router.post('/setControlBoxPosition', setControlBoxPosition);
|
||||||
router.post('/summarizeAI', summarizeAI.fields([
|
router.post('/summarizeAI', summarizeAI.fields([
|
||||||
{ name: 'prompt_file', maxCount: 1 },
|
{ name: 'prompt_file', maxCount: 1 },
|
||||||
{ name: 'schema_file', maxCount: 1 },
|
{ name: 'schema_file', maxCount: 1 },
|
||||||
]), summarizeAI_action);
|
]), summarizeAI_action);
|
||||||
router.post('/addSummarizeAiLog', addSummarizeAiLog);
|
router.post('/addSummarizeAiLog', activityLogger('summarizeAI'), addSummarizeAiLog);
|
||||||
router.get('/downloadzip', downloadzip);
|
router.get('/downloadzip', downloadzip);
|
||||||
router.get('/summarizeState', summarizeState)
|
router.get('/summarizeState', summarizeState)
|
||||||
router.get('/summarizeState', summarizeState)
|
router.get('/summarizeState', summarizeState)
|
||||||
router.get('/getMyDownloadList', getMyDownloadList);
|
router.get('/getMyDownloadList', getMyDownloadList);
|
||||||
router.post('/addPermissionLog', addPermissionLog);
|
router.post('/addPermissionLog', activityLogger('addPermission'), addPermissionLog);
|
||||||
|
|
||||||
router.get('/getNullThumbnailDataInfo', getNullThumbnailDataInfo);
|
router.get('/getNullThumbnailDataInfo', getNullThumbnailDataInfo);
|
||||||
router.post('/updateThumbnailInfo', updateThumbnailInfo);
|
router.post('/updateThumbnailInfo', updateThumbnailInfo);
|
||||||
|
|||||||
14
scratch_check_header.js
Normal file
14
scratch_check_header.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const fileData = fs.readFileSync('downloaded_test.hwpx');
|
||||||
|
const hex = fileData.subarray(0, 16).toString('hex').toUpperCase();
|
||||||
|
console.log('Hex signature of first 16 bytes:');
|
||||||
|
console.log(hex.match(/.{1,2}/g).join(' '));
|
||||||
|
|
||||||
|
if (hex.startsWith('D0CF11E0A1B11AE1')) {
|
||||||
|
console.log('File type: OLE Compound File (standard HWP binary)');
|
||||||
|
} else if (hex.startsWith('504B0304')) {
|
||||||
|
console.log('File type: ZIP file (standard HWPX)');
|
||||||
|
} else {
|
||||||
|
console.log('File type: Unknown');
|
||||||
|
}
|
||||||
29
scratch_download_hwp.js
Normal file
29
scratch_download_hwp.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const onPremiseClient = require('./config/onPremiseClient.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const bucketName = 'pm-test-04';
|
||||||
|
const objectKey = 'archive/origin/01_TEST01_산출물/A/2. 뷰잉 테스트/test_hwpx.hwpx__260616-103002-723';
|
||||||
|
|
||||||
|
console.log(`Downloading ${objectKey} from ${bucketName}...`);
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Key: objectKey
|
||||||
|
});
|
||||||
|
const response = await onPremiseClient.send(command);
|
||||||
|
|
||||||
|
// Write to local file
|
||||||
|
const writeStream = fs.createWriteStream('downloaded_test.hwpx');
|
||||||
|
response.Body.pipe(writeStream);
|
||||||
|
|
||||||
|
writeStream.on('finish', () => {
|
||||||
|
console.log('Download finished. File saved as downloaded_test.hwpx');
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
47
scratch_inspect_hwp.js
Normal file
47
scratch_inspect_hwp.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const hwp = require('./libs/hwp.js');
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
console.log("Parsing downloaded_test.hwpx...");
|
||||||
|
try {
|
||||||
|
const fileBuffer = fs.readFileSync('downloaded_test.hwpx');
|
||||||
|
const doc = hwp.parse(fileBuffer, { type: 'buffer' });
|
||||||
|
|
||||||
|
console.log("\nDocument parsed successfully.");
|
||||||
|
console.log(`Number of sections: ${doc.sections.length}`);
|
||||||
|
|
||||||
|
doc.sections.forEach((section, sIdx) => {
|
||||||
|
console.log(`\nSection ${sIdx}:`);
|
||||||
|
console.log(` Page width: ${section.width} (${section.width / 7200} in)`);
|
||||||
|
console.log(` Page height: ${section.height} (${section.height / 7200} in)`);
|
||||||
|
console.log(` PaddingTop: ${section.paddingTop}`);
|
||||||
|
console.log(` HeaderPadding: ${section.headerPadding}`);
|
||||||
|
console.log(` PaddingLeft: ${section.paddingLeft}`);
|
||||||
|
|
||||||
|
let shapeCount = 0;
|
||||||
|
section.content.forEach((para, pIdx) => {
|
||||||
|
para.controls.forEach((ctrl, cIdx) => {
|
||||||
|
// Check if it's a shape (Rectangle, Ellipse, Line, etc.)
|
||||||
|
// Check properties
|
||||||
|
const isShape = ctrl.type !== 1347570004; // Not table
|
||||||
|
if (isShape) {
|
||||||
|
shapeCount++;
|
||||||
|
console.log(`\n --- Shape #${shapeCount} inside Paragraph ${pIdx} ---`);
|
||||||
|
console.log(` Control ID: ${ctrl.id} / Type: ${ctrl.type}`);
|
||||||
|
console.log(` Width: ${ctrl.width} (${ctrl.width / 100} pt)`);
|
||||||
|
console.log(` Height: ${ctrl.height} (${ctrl.height / 100} pt)`);
|
||||||
|
console.log(` vertRelTo: ${ctrl.attribute ? ctrl.attribute.vertRelTo : 'N/A'}`);
|
||||||
|
console.log(` verticalOffset: ${ctrl.verticalOffset} (${ctrl.verticalOffset / 100} pt)`);
|
||||||
|
console.log(` horizontalOffset: ${ctrl.horizontalOffset} (${ctrl.horizontalOffset / 100} pt)`);
|
||||||
|
console.log(` zIndex: ${ctrl.zIndex}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log(`\nTotal shapes in section ${sIdx}: ${shapeCount}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error parsing HWP:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
57
scratch_parse_hwpx.js
Normal file
57
scratch_parse_hwpx.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const jszip = require('jszip');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const fileData = fs.readFileSync('downloaded_test.hwpx');
|
||||||
|
const zip = await jszip.loadAsync(fileData);
|
||||||
|
|
||||||
|
// List files in the HWPX zip
|
||||||
|
console.log('Zip file entries:');
|
||||||
|
zip.forEach((relativePath, file) => {
|
||||||
|
console.log(' -', relativePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find section0.xml or similar XML files containing text content
|
||||||
|
const sectionFile = Object.keys(zip.files).find(name => name.includes('section') && name.endsWith('.xml'));
|
||||||
|
if (sectionFile) {
|
||||||
|
console.log(`\nReading ${sectionFile}...`);
|
||||||
|
const content = await zip.file(sectionFile).async('text');
|
||||||
|
|
||||||
|
// Search for shapes
|
||||||
|
// In HWPX, shapes are under <hp:rect>, <hp:ellipse>, <hp:line> etc.
|
||||||
|
// Let's write them to a local file for inspection
|
||||||
|
fs.writeFileSync('section0.xml', content);
|
||||||
|
console.log('Main section XML saved as section0.xml');
|
||||||
|
|
||||||
|
// Regex to find shape properties
|
||||||
|
// HWPX coordinates: vertRelTo, horzRelTo, vertAlign, horzAlign, vertOffset, horzOffset
|
||||||
|
console.log('\nSearching for shape properties in section XML:');
|
||||||
|
const shapeObjectRegex = /<hp:shapeObject[^>]*>([\s\S]*?)<\/hp:shapeObject>/g;
|
||||||
|
let match;
|
||||||
|
let count = 0;
|
||||||
|
while ((match = shapeObjectRegex.exec(content)) !== null) {
|
||||||
|
count++;
|
||||||
|
console.log(`\n--- Shape Object #${count} ---`);
|
||||||
|
const shapeObjectHeader = match[0].substring(0, match[0].indexOf('>') + 1);
|
||||||
|
console.log('Header:', shapeObjectHeader);
|
||||||
|
|
||||||
|
// Find vertRelTo, horzRelTo, etc.
|
||||||
|
const attrs = ['vertRelTo', 'horzRelTo', 'vertAlign', 'horzAlign', 'vertOffset', 'horzOffset'];
|
||||||
|
attrs.forEach(attr => {
|
||||||
|
const reg = new RegExp(`${attr}="([^"]*)"`);
|
||||||
|
const m = reg.exec(shapeObjectHeader);
|
||||||
|
if (m) {
|
||||||
|
console.log(` ${attr}: ${m[1]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No section XML file found.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing HWPX:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
28
scratch_query_hwp.js
Normal file
28
scratch_query_hwp.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const pool = require("./db/pool.js");
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
console.log("Querying HWP/HWPX files from database...");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ver4._test_tb_data
|
||||||
|
WHERE ext IN ('hwp', 'hwpx')
|
||||||
|
ORDER BY data_id DESC
|
||||||
|
LIMIT 5;
|
||||||
|
`;
|
||||||
|
const res = await client.query(query);
|
||||||
|
console.log("Results:");
|
||||||
|
res.rows.forEach(row => {
|
||||||
|
console.log(JSON.stringify(row, null, 2));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error checking HWP files:", err);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -407,6 +407,46 @@
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pagination styling */
|
||||||
|
.pagination-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: var(--primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn.active {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
table.admin-table {
|
table.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -814,7 +854,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">📢 실시간 배너 공지사항 등록</h3>
|
<h3 class="card-title">📢 실시간 배너 공지사항 등록</h3>
|
||||||
</div>
|
</div>
|
||||||
<form class="config-form" style="gap: 20px;" onsubmit="handleBannerSubmit(event)">
|
<form class="config-form" style="gap: 20px;" onsubmit="submitBannerForm(event)">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="banner-project-select">공지 대상 프로젝트</label>
|
<label for="banner-project-select">공지 대상 프로젝트</label>
|
||||||
@@ -1030,6 +1070,8 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Pagination Controls -->
|
||||||
|
<div id="audit-log-pagination" class="pagination-container"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1428,6 +1470,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 날짜 문자열을 클라이언트 로컬 타임존 기반의 YYYY-MM-DD HH:mm:ss 형식으로 변환하는 함수
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
|
const YYYY = date.getFullYear();
|
||||||
|
const MM = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const DD = String(date.getDate()).padStart(2, '0');
|
||||||
|
const HH = String(date.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 로그인 사용자 프로필 로드 함수
|
// 로그인 사용자 프로필 로드 함수
|
||||||
async function loadUserProfile() {
|
async function loadUserProfile() {
|
||||||
try {
|
try {
|
||||||
@@ -2275,7 +2331,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 7. 활동 로그 조회 탭 (Activity Logs) ---
|
// --- 7. 활동 로그 조회 탭 (Activity Logs) ---
|
||||||
async function renderAuditLogs() {
|
async function renderAuditLogs(page = 1) {
|
||||||
const userId = document.getElementById('search-log-user').value.trim();
|
const userId = document.getElementById('search-log-user').value.trim();
|
||||||
const projectNm = document.getElementById('search-log-project').value.trim();
|
const projectNm = document.getElementById('search-log-project').value.trim();
|
||||||
const action = document.getElementById('filter-log-action').value.trim();
|
const action = document.getElementById('filter-log-action').value.trim();
|
||||||
@@ -2284,16 +2340,22 @@
|
|||||||
if (userId) params.append('user_id', userId);
|
if (userId) params.append('user_id', userId);
|
||||||
if (projectNm) params.append('project_nm', projectNm);
|
if (projectNm) params.append('project_nm', projectNm);
|
||||||
if (action) params.append('activity', action);
|
if (action) params.append('activity', action);
|
||||||
|
params.append('page', page);
|
||||||
|
params.append('limit', 20);
|
||||||
|
|
||||||
let queryUrl = `/api/admin/audit-logs?${params.toString()}`;
|
let queryUrl = `/api/admin/audit-logs?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const logs = await fetchAPI(queryUrl);
|
const resData = await fetchAPI(queryUrl);
|
||||||
|
const logs = resData.data;
|
||||||
|
const pagination = resData.pagination;
|
||||||
|
|
||||||
const body = document.getElementById('audit-log-body');
|
const body = document.getElementById('audit-log-body');
|
||||||
body.innerHTML = '';
|
body.innerHTML = '';
|
||||||
|
|
||||||
if (logs.length === 0) {
|
if (!logs || logs.length === 0) {
|
||||||
body.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-light); padding: 24px 0;">조회된 활동 로그 내역이 없습니다.</td></tr>`;
|
body.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-light); padding: 24px 0;">조회된 활동 로그 내역이 없습니다.</td></tr>`;
|
||||||
|
document.getElementById('audit-log-pagination').innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2305,9 +2367,11 @@
|
|||||||
cleanPath = '/' + log.criteria_info.filter(p => p).join('/');
|
cleanPath = '/' + log.criteria_info.filter(p => p).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowNumber = pagination.total - ((pagination.page - 1) * pagination.limit) - idx;
|
||||||
|
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${idx + 1}</td>
|
<td>${rowNumber}</td>
|
||||||
<td>${log.clean_date ? log.clean_date.replace('T', ' ').substring(0, 19) : '-'}</td>
|
<td>${formatDate(log.clean_date)}</td>
|
||||||
<td><strong>${log.project_nm || log.project_id || '-'}</strong></td>
|
<td><strong>${log.project_nm || log.project_id || '-'}</strong></td>
|
||||||
<td>${log.user_id || '-'}</td>
|
<td>${log.user_id || '-'}</td>
|
||||||
<td>${log.user_ip || '-'}</td>
|
<td>${log.user_ip || '-'}</td>
|
||||||
@@ -2316,11 +2380,53 @@
|
|||||||
`;
|
`;
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
renderPaginationControls(pagination);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPaginationControls(pagination) {
|
||||||
|
const container = document.getElementById('audit-log-pagination');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const { page, totalPages } = pagination;
|
||||||
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
|
const prevBtn = document.createElement('button');
|
||||||
|
prevBtn.className = 'pagination-btn';
|
||||||
|
prevBtn.innerText = '이전';
|
||||||
|
prevBtn.disabled = page === 1;
|
||||||
|
prevBtn.onclick = () => renderAuditLogs(page - 1);
|
||||||
|
container.appendChild(prevBtn);
|
||||||
|
|
||||||
|
const maxPageButtons = 5;
|
||||||
|
let startPage = Math.max(1, page - Math.floor(maxPageButtons / 2));
|
||||||
|
let endPage = startPage + maxPageButtons - 1;
|
||||||
|
|
||||||
|
if (endPage > totalPages) {
|
||||||
|
endPage = totalPages;
|
||||||
|
startPage = Math.max(1, endPage - maxPageButtons + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const pageBtn = document.createElement('button');
|
||||||
|
pageBtn.className = `pagination-btn ${i === page ? 'active' : ''}`;
|
||||||
|
pageBtn.innerText = i;
|
||||||
|
pageBtn.onclick = () => renderAuditLogs(i);
|
||||||
|
container.appendChild(pageBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextBtn = document.createElement('button');
|
||||||
|
nextBtn.className = 'pagination-btn';
|
||||||
|
nextBtn.innerText = '다음';
|
||||||
|
nextBtn.disabled = page === totalPages;
|
||||||
|
nextBtn.onclick = () => renderAuditLogs(page + 1);
|
||||||
|
container.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
// --- 8. 글로벌 삭제 정책 설정 탭 (Policies) ---
|
// --- 8. 글로벌 삭제 정책 설정 탭 (Policies) ---
|
||||||
async function renderDeletePolicy() {
|
async function renderDeletePolicy() {
|
||||||
try {
|
try {
|
||||||
@@ -2345,7 +2451,7 @@
|
|||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${idx + 1}</td>
|
<td>${idx + 1}</td>
|
||||||
<td>${l.clean_date ? l.clean_date.replace('T', ' ').substring(0, 19) : '-'}</td>
|
<td>${formatDate(l.clean_date)}</td>
|
||||||
<td><strong>${l.project_id}</strong></td>
|
<td><strong>${l.project_id}</strong></td>
|
||||||
<td style="max-width: 250px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${l.clean_path}</td>
|
<td style="max-width: 250px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${l.clean_path}</td>
|
||||||
<td>${l.criteria_info}</td>
|
<td>${l.criteria_info}</td>
|
||||||
|
|||||||
@@ -15,3 +15,48 @@
|
|||||||
|
|
||||||
/* 폰트적용 */
|
/* 폰트적용 */
|
||||||
/* * { font-family: 'Noto Sans KR', 'Protest Riot', 'Gowun Dodum', sans-serif; } */
|
/* * { font-family: 'Noto Sans KR', 'Protest Riot', 'Gowun Dodum', sans-serif; } */
|
||||||
|
|
||||||
|
/* Noto Sans KR & Noto Serif KR 임포트 */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&family=Noto+Serif+KR:wght@300;400;700&display=swap');
|
||||||
|
|
||||||
|
/* 한양굴림, 한컴바탕 등 한글 기본 폰트의 웹 폰트 대체 매핑 */
|
||||||
|
@font-face {
|
||||||
|
font-family: '바탕';
|
||||||
|
src: local('바탕'), local('Batang'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '바탕체';
|
||||||
|
src: local('바탕체'), local('BatangChe'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '함초롬바탕';
|
||||||
|
src: local('함초롬바탕'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '돋움';
|
||||||
|
src: local('돋움'), local('Dotum'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '돋움체';
|
||||||
|
src: local('돋움체'), local('DotumChe'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '함초롬돋움';
|
||||||
|
src: local('함초롬돋움'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '굴림';
|
||||||
|
src: local('굴림'), local('Gulim'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '굴림체';
|
||||||
|
src: local('굴림체'), local('GulimChe'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '궁서';
|
||||||
|
src: local('궁서'), local('Gungsuh'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: '궁서체';
|
||||||
|
src: local('궁서체'), local('GungsuhChe'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
|
||||||
|
}
|
||||||
@@ -425,7 +425,7 @@ body > .control-box .contents-wrap .btn-wrap .btn.selected.map .icon { backgroun
|
|||||||
|
|
||||||
|
|
||||||
/* 아카이브 우측 영역 (미리보기, 메모(AI요약), 메타데이터) */
|
/* 아카이브 우측 영역 (미리보기, 메모(AI요약), 메타데이터) */
|
||||||
.archive-main-right { min-width: 41rem; max-width: 41rem; height: 100%; background: #fff; position: relative; }
|
.archive-main-right { min-width: 55rem; max-width: 55rem; height: 100%; background: #fff; position: relative; }
|
||||||
|
|
||||||
.archive-main-right .viewer-container { display: none; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; position: relative; }
|
.archive-main-right .viewer-container { display: none; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; position: relative; }
|
||||||
|
|
||||||
@@ -1184,12 +1184,12 @@ body > .control-box .contents-wrap .btn-wrap .btn.selected.map .icon { backgroun
|
|||||||
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-negative h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; }
|
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-negative h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; }
|
||||||
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-positive h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; }
|
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-positive h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; }
|
||||||
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-body h4 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; }
|
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-body h4 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; }
|
||||||
/* .archive-main-center .list-notice .list-notice-box-toggle { background-color: #fff; border: 0.063rem solid #ddd; padding: 0.375rem; border-radius: 0.25rem; display: flex; cursor: pointer; position: absolute; z-index: 11; top: 2.5rem; right: 41.5rem; } */
|
/* .archive-main-center .list-notice .list-notice-box-toggle { background-color: #fff; border: 0.063rem solid #ddd; padding: 0.375rem; border-radius: 0.25rem; display: flex; cursor: pointer; position: absolute; z-index: 11; top: 2.5rem; right: 55.5rem; } */
|
||||||
.list-notice-box-toggle { background-color: #fff; border: 0.0625rem solid #ddd; border-radius: 0.25rem; padding: 0.25rem; cursor: pointer; position: absolute; z-index: 40; top: 0.625rem; right: 0.625rem; }
|
.list-notice-box-toggle { background-color: #fff; border: 0.0625rem solid #ddd; border-radius: 0.25rem; padding: 0.25rem; cursor: pointer; position: absolute; z-index: 40; top: 0.625rem; right: 0.625rem; }
|
||||||
|
|
||||||
|
|
||||||
.archive-main-center .list-notice .list-notice-box-toggle:hover { background-color: #eee;}
|
.archive-main-center .list-notice .list-notice-box-toggle:hover { background-color: #eee;}
|
||||||
.archive-main-center .list-notice .list-notice-box-toggle-menu { display: flex; flex-direction: column; gap: 0.25rem; background-color: #fff; border: 0.063rem solid #ddd; border-radius: 0.25rem; padding: 0.5rem 1rem; min-width: 12rem; max-width:12rem; position: absolute; z-index: 11; top: 4.82rem; right: 41.5rem; box-shadow: 0rem 0.5rem 1.5rem 0rem rgba(0, 0, 0, 0.16); }
|
.archive-main-center .list-notice .list-notice-box-toggle-menu { display: flex; flex-direction: column; gap: 0.25rem; background-color: #fff; border: 0.063rem solid #ddd; border-radius: 0.25rem; padding: 0.5rem 1rem; min-width: 12rem; max-width:12rem; position: absolute; z-index: 11; top: 4.82rem; right: 55.5rem; box-shadow: 0rem 0.5rem 1.5rem 0rem rgba(0, 0, 0, 0.16); }
|
||||||
.archive-main-center .list-notice .list-notice-box-toggle-menu-list { display: flex; align-items: center; gap: 0.5rem; }
|
.archive-main-center .list-notice .list-notice-box-toggle-menu-list { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
.archive-main-center .list-notice .list-notice-box-toggle-menu-list:hover { cursor: pointer; background: #ccc; }
|
.archive-main-center .list-notice .list-notice-box-toggle-menu-list:hover { cursor: pointer; background: #ccc; }
|
||||||
.archive-main-center .list-notice .list-notice-box-toggle-menu-list h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; }
|
.archive-main-center .list-notice .list-notice-box-toggle-menu-list h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; }
|
||||||
|
|||||||
@@ -5487,6 +5487,7 @@ export async function renderViewer(resourcePath, dataId, shouldAddClickLog = tru
|
|||||||
|
|
||||||
function viewerExcel(presignedUrl) {
|
function viewerExcel(presignedUrl) {
|
||||||
vars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
|
vars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
|
||||||
|
initMainFallbackPdfButton(dataId, resourcePath, objectKey, previewKey);
|
||||||
|
|
||||||
fetch(presignedUrl)
|
fetch(presignedUrl)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
@@ -5616,18 +5617,33 @@ export async function renderViewer(resourcePath, dataId, shouldAddClickLog = tru
|
|||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.style.width = '100%';
|
container.style.width = '100%';
|
||||||
container.style.height = '100%';
|
container.style.height = '100%';
|
||||||
container.style.overflow = 'auto';
|
container.style.overflowX = 'auto';
|
||||||
|
container.style.overflowY = 'auto';
|
||||||
container.style.padding = '20px';
|
container.style.padding = '20px';
|
||||||
container.style.boxSizing = 'border-box';
|
container.style.boxSizing = 'border-box';
|
||||||
container.style.background = '#f5f5f5';
|
container.style.background = '#f5f5f5';
|
||||||
|
|
||||||
|
const styleEl = document.createElement('style');
|
||||||
|
styleEl.textContent = `
|
||||||
|
.hwp-inner-container {
|
||||||
|
background: #ffffff;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 800px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||||
|
padding: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.hwp-inner-container img {
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
container.appendChild(styleEl);
|
||||||
|
|
||||||
const hwpInner = document.createElement('div');
|
const hwpInner = document.createElement('div');
|
||||||
hwpInner.style.background = '#ffffff';
|
hwpInner.classList.add('hwp-inner-container');
|
||||||
hwpInner.style.margin = '0 auto';
|
|
||||||
hwpInner.style.maxWidth = '800px';
|
|
||||||
hwpInner.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)';
|
|
||||||
hwpInner.style.padding = '40px';
|
|
||||||
hwpInner.style.minHeight = '100%';
|
|
||||||
|
|
||||||
container.appendChild(hwpInner);
|
container.appendChild(hwpInner);
|
||||||
vars.viewer.appendChild(container);
|
vars.viewer.appendChild(container);
|
||||||
|
|||||||
@@ -733,6 +733,7 @@ export async function renderDocViewer(resourcePath, docId) {
|
|||||||
|
|
||||||
function viewerExcel(presignedUrl) {
|
function viewerExcel(presignedUrl) {
|
||||||
docVars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
|
docVars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
|
||||||
|
initDocFallbackPdfButton(docId, resourcePath, objectKey, previewKey);
|
||||||
|
|
||||||
fetch(presignedUrl)
|
fetch(presignedUrl)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
@@ -862,7 +863,7 @@ export async function renderDocViewer(resourcePath, docId) {
|
|||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.style.width = '100%';
|
container.style.width = '100%';
|
||||||
container.style.height = '100%';
|
container.style.height = '100%';
|
||||||
container.style.overflowX = 'hidden';
|
container.style.overflowX = 'auto';
|
||||||
container.style.overflowY = 'auto';
|
container.style.overflowY = 'auto';
|
||||||
container.style.padding = '20px';
|
container.style.padding = '20px';
|
||||||
container.style.boxSizing = 'border-box';
|
container.style.boxSizing = 'border-box';
|
||||||
@@ -873,38 +874,17 @@ export async function renderDocViewer(resourcePath, docId) {
|
|||||||
.hwp-inner-container {
|
.hwp-inner-container {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 800px;
|
width: max-content;
|
||||||
|
min-width: 800px;
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||||
padding: 30px !important;
|
padding: 0 !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
.hwp-inner-container > div > div {
|
|
||||||
max-width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
padding-left: 20px !important;
|
|
||||||
padding-right: 20px !important;
|
|
||||||
margin-bottom: 20px !important;
|
|
||||||
}
|
|
||||||
.hwp-inner-container table {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
table-layout: fixed !important;
|
|
||||||
}
|
|
||||||
.hwp-inner-container img {
|
.hwp-inner-container img {
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
|
||||||
.hwp-inner-container {
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
.hwp-inner-container > div > div {
|
|
||||||
padding-left: 10px !important;
|
|
||||||
padding-right: 10px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
container.appendChild(styleEl);
|
container.appendChild(styleEl);
|
||||||
|
|
||||||
|
|||||||
@@ -674,6 +674,10 @@ function _openExcel(path, data) {
|
|||||||
const viewer = document.getElementById('popup_viewer');
|
const viewer = document.getElementById('popup_viewer');
|
||||||
viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
|
viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
|
||||||
|
|
||||||
|
if (dataId && path_name) {
|
||||||
|
initFallbackPdfButton(dataId, path_name, resourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
fetch(path)
|
fetch(path)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||||
@@ -815,7 +819,7 @@ function _openHwp(path, data) {
|
|||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.style.width = '100%';
|
container.style.width = '100%';
|
||||||
container.style.height = '100%';
|
container.style.height = '100%';
|
||||||
container.style.overflowX = 'hidden';
|
container.style.overflowX = 'auto';
|
||||||
container.style.overflowY = 'auto';
|
container.style.overflowY = 'auto';
|
||||||
container.style.padding = '20px';
|
container.style.padding = '20px';
|
||||||
container.style.boxSizing = 'border-box';
|
container.style.boxSizing = 'border-box';
|
||||||
@@ -826,38 +830,17 @@ function _openHwp(path, data) {
|
|||||||
.hwp-inner-container {
|
.hwp-inner-container {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 800px;
|
width: max-content;
|
||||||
|
min-width: 800px;
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||||
padding: 30px !important;
|
padding: 0 !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
.hwp-inner-container > div > div {
|
|
||||||
max-width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
padding-left: 20px !important;
|
|
||||||
padding-right: 20px !important;
|
|
||||||
margin-bottom: 20px !important;
|
|
||||||
}
|
|
||||||
.hwp-inner-container table {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
table-layout: fixed !important;
|
|
||||||
}
|
|
||||||
.hwp-inner-container img {
|
.hwp-inner-container img {
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
|
||||||
.hwp-inner-container {
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
.hwp-inner-container > div > div {
|
|
||||||
padding-left: 10px !important;
|
|
||||||
padding-right: 10px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
container.appendChild(styleEl);
|
container.appendChild(styleEl);
|
||||||
|
|
||||||
|
|||||||
@@ -3483,6 +3483,9 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script>
|
||||||
|
|
||||||
|
<!-- wmf.js (WMF 이미지 렌더러) -->
|
||||||
|
<script src="/libs/wmf.js"></script>
|
||||||
|
|
||||||
<!-- hwp.js (한글 뷰어) 스크립트 -->
|
<!-- hwp.js (한글 뷰어) 스크립트 -->
|
||||||
<script src="/libs/hwp.js"></script>
|
<script src="/libs/hwp.js"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,11 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script>
|
||||||
|
|
||||||
|
<!-- font.css (한글 기본 폰트 매핑) -->
|
||||||
|
<link rel="stylesheet" href="/main/css/font.css">
|
||||||
|
<!-- wmf.js (WMF 이미지 렌더링) -->
|
||||||
|
<script src="/libs/wmf.js"></script>
|
||||||
|
|
||||||
<!-- hwp.js (한글 뷰어) 스크립트 -->
|
<!-- hwp.js (한글 뷰어) 스크립트 -->
|
||||||
<script src="/libs/hwp.js"></script>
|
<script src="/libs/hwp.js"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown.min.css">
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
관리자화면기획안.csv
11
관리자화면기획안.csv
@@ -1,11 +0,0 @@
|
|||||||
대분류,메뉴명,상세 기능 명세,추가 고려사항 (고급 기능)
|
|
||||||
프로젝트 관리,프로젝트 등록 및 설정,신규 현장/공구의 생성/수정 및 용량 제한(storage_byte) 설정과 잠금 토글(is_active) 관리,비공개/보안(Secret) 폴더에 대한 일반 유저 접근 신청 결재 및 임시 승인 기능
|
|
||||||
프로젝트 관리,실시간 배너 공지,대시보드 상단 마르퀴 띠배너 공지사항(banner_notice) 등록 및 소켓 기반 실시간 송출 제어,배너 공지 예약 노출 및 송출 이력 관리
|
|
||||||
사용자 권한 관리,사용자 계정 제어,"사용자 계정 등록, 정보(부서/직급) 수정, 비밀번호 재설정 및 퇴사자 계정 잠금(is_resigned) 처리",외부 SSO(Sentinel) 계정 연동 및 중복 로그인 차단 옵션 관리
|
|
||||||
사용자 권한 관리,프로젝트 권한 배정,현장별 참여 유저 목록 배정 및 권한 등급(Master/Sub-Master/Worker/Viewer) 클릭 지정,폴더 수준의 세부 접근 제어 리스트(ACL) 추가 연동
|
|
||||||
서버 및 리소스 모니터링,용량 분석 현황,"현장별 누적 스토리지 사용량, 남은 용량 및 파일/폴더 개수 대시보드 시각화",스토리지 임계값(예: 90%) 도달 시 관리자 자동 경고 알림
|
|
||||||
서버 및 리소스 모니터링,압축 다운로드 관리,비동기 폴더 압축 다운로드(BullMQ) 작업의 진행 현황 모니터링 및 대기열 제어,임시 보존 기간이 지난 압축 임시파일 자동/일괄 영구 삭제를 통한 디스크 확보
|
|
||||||
시스템 감사 및 통계,감사 로그 조회,"파일 삭제, 이동, 다운로드 등 민감한 조작 행위(tb_log)의 날짜/유저/활동별 필터 검색",감사 로그 이력 보고서 인쇄 및 엑셀 백업 다운로드
|
|
||||||
시스템 감사 및 통계,행동 분석 및 통계,사용자 UI 클릭 로그(tb_click_log) 기반 최다 접근 메뉴 및 최다 조회 도면/문서 사용 현황 분석,AI 요약 서비스(Gemini API) 호출 제한량(Quota) 관리 및 비용 통제
|
|
||||||
실시간 접속자,실시간 접속 현황,현재 소켓 연결된 동시 접속 유저 목록 및 접속 IP와 현재 탐색 중인 아카이브 경로 실시간 표출,특정 유저 강제 소켓 연결 끊기(Kick) 기능
|
|
||||||
시스템 환경 및 정책 관리,보관 및 삭제 정책 설정,기존 pageRenderer.js에 하드코딩된 '최소 유지 파일 개수(3개)' 및 '보존 기간(15일)' 임계치를 DB화하여 관리자 UI에서 동적 설정 기능 제공,tb_project 테이블에 limit_file_count 및 limit_days 컬럼을 연동하여 프론트엔드가 변경된 기준값으로 실시간 바인딩되도록 API 개선
|
|
||||||
|
48
문서 뷰어(미리보기) 확장자별 지원 사양 및 기술 사유 분석.md
Normal file
48
문서 뷰어(미리보기) 확장자별 지원 사양 및 기술 사유 분석.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 문서 뷰어(미리보기) 확장자별 지원 사양 및 기술 사유 분석
|
||||||
|
|
||||||
|
본 문서는 본 프로젝트의 문서 미리보기 시스템에서 **지원 가능한 파일 확장자 목록과 구현 방식**, 그리고 **일부 포맷이 미리보기가 불가능하거나 제한적인 기술적 사유**를 체계적으로 정리한 기술 문서입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 지원 가능한 확장자 및 뷰잉 방식 (Supported Formats)
|
||||||
|
시스템에서 감지 및 렌더링이 가능한 확장자는 크게 **(1) 웹 브라우저 직접 렌더링 방식**, **(2) 서버 측 PDF 변환 폴백 방식**, **(3) 전문 뷰어 연동 방식**으로 분류됩니다.
|
||||||
|
|
||||||
|
### 1.1 문서 및 도면 파일 (Document & CAD)
|
||||||
|
| 확장자 | 브라우저 직접 뷰잉 방식 | 서버 PDF 변환 뷰잉 | 미리보기 지원 상태 및 특이사항 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **pdf** | 자체 PDF.js 뷰어로 즉시 출력 | (변환 불필요) | **[상시 지원]** 표준 문서 포맷으로 100% 원본 렌더링 지원 |
|
||||||
|
| **hwp, hwpx** | `hwp.js` 라이브러리로 직접 뷰잉 | **LibreOffice PDF 변환본 로드** | **[상시 지원]** 직접 뷰잉 도중 레이아웃 겹침 발생 시 상단 "PDF로 보기" 버튼을 눌러 원본 고정밀 보기 지원 |
|
||||||
|
| **docx** | `docx-preview` 라이브러리로 직접 뷰잉 | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 직접 뷰잉 도중 서식 누락 발생 시 상단 "PDF로 보기" 버튼을 눌러 원본 고정밀 보기 지원 |
|
||||||
|
| **xlsx, xls, xlsm** | `Luckysheet/LuckyExcel`로 시트 뷰잉 | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 직접 시트 뷰잉 시 도형(Shapes)이 안 보일 때 상단 "PDF로 보기" 버튼을 눌러 원본 고정밀 보기 지원 |
|
||||||
|
| **doc** | 직접 뷰잉 불가 (이진 포맷 한계) | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
|
||||||
|
| **ppt, pptx** | 직접 뷰잉 불가 (웹 파서 미비) | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
|
||||||
|
| **dwg, dxf** | 직접 뷰잉 불가 (CAD 도면 엔진 필요) | **DwgToPdfSwigConverter 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
|
||||||
|
| **grm** | 직접 뷰잉 불가 | **서버 PDF 변환본 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
|
||||||
|
|
||||||
|
### 1.2 미디어 및 기타 포맷 (Media & Others)
|
||||||
|
| 분류 | 확장자 | 브라우저 렌더링 방식 | 상세 설명 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **이미지** | `png, jpg, jpeg, webp, gif` | `<img>` 태그 렌더링 | 브라우저 표준 그래픽 렌더링 사용. 이미지 메타데이터가 파노라마(`panorama`)인 경우 별도의 3D 파노라마 뷰어(`pannellum`) 연동 |
|
||||||
|
| **동영상** | `mp4, mov, webm` | `<video>` 태그 렌더링 | HTML5 표준 비디오 코덱 플레이어 사용 |
|
||||||
|
| **텍스트** | `txt, log, md` | `<pre>` 태그 렌더링 | 인코딩 감지 후 플레인 텍스트 렌더링 (마크다운은 스타일링 적용) |
|
||||||
|
| **3D 모델** | `glb, gltf, obj, stl, fbx, 3dm` | `Three.js` 3D 뷰어 연동 | WebGL 기반의 3D 객체 직접 렌더링 및 회전/스케일 제어 지원 |
|
||||||
|
| **공간 정보** | `gsim` | GSIM 전용 뷰어 웹앱 연동 | 별도 마운트된 GSIM 엔진 프레임 내부 렌더링 |
|
||||||
|
| **BIM 모델** | `ifc` | IFC 뷰어 전용 iframe 연동 | `web-ifc-three` 기반의 BIM 모델링 구조 브라우저 직접 렌더링 |
|
||||||
|
| **기타** | `zip` | 폴더/파일 트리 렌더링 | 압축을 풀지 않고 내부 디렉토리 아키텍처를 JSON으로 트리 뷰잉 |
|
||||||
|
| **링크** | `url` | iframe 또는 새 창 이동 | 등록된 외부 URL 리다이렉트 처리 |
|
||||||
|
| **웹문서** | `html` | iframe 렌더링 | HTML 샌드박스 프레임 내부 렌더링 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 미리보기 미지원 확장자 및 기술 사유 (Unsupported Formats)
|
||||||
|
아래 확장자들은 **미리보기가 불가능하며 오직 파일 다운로드만 지원**됩니다. 그 구체적인 기술 사유는 다음과 같습니다.
|
||||||
|
|
||||||
|
| 파일 분류 | 대상 확장자 (예시) | 미리보기 미지원 기술 사유 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **실행 파일** | `exe, msi, bat, sh, cmd, com` | * **보안 위험**: 웹 브라우저 내에서 OS 실행 파일을 구동하는 것은 크로스 사이트 스크립팅(XSS) 및 악성코드 실행 방지를 위해 보안상 원천 차단됩니다. <br> * **플랫폼 독립성**: 브라우저 샌드박스 환경에서는 로컬 머신의 커널에 직접 액세스하여 실행 프로그램을 실행할 수 없습니다. |
|
||||||
|
| **압축 파일** | `rar, 7z, tar, gz, alz, egg` | * **압축 알고리즘 독점성**: `.zip`을 제외한 `.rar`, `.7z` 등은 압축 및 해제 알고리즘이 브라우저 JavaScript 단에서 처리하기에 라이브러리가 매우 무겁거나, 유료 라이선스 제약(독점 포맷)이 있습니다. <br> * **성능 저하**: 클라이언트 브라우저 메모리 상에서 기가바이트 단위의 대용량 압축 파일을 직접 해제하여 트리 구조를 빌드하는 것은 브라우저 탭 다운을 유발합니다. |
|
||||||
|
| **데이터베이스** | `db, sqlite, mdb, sql, accdb` | * **연결 세션성**: DB 파일은 전용 DBMS(엔진)가 백그라운드에서 구동되어 인덱스 쿼리를 수행해야 구조 열람이 가능합니다. 브라우저에서 바이너리 파일 그 자체를 텍스트나 그림처럼 단순 뷰잉하는 것은 논리적으로 불가능합니다. |
|
||||||
|
| **오디오 파일** | `mp3, wav, ogg, flac, m4a` | * **기획적 요구 부재**: 시스템이 문서 관리 및 3D 모델 협업 중심이므로 오디오 플레이어 탑재의 우선순위가 배제되어 있으며, 필요 시 브라우저 내장 플레이어로 다운로드 대체가 가능합니다. |
|
||||||
|
| **소스 코드** | `java, py, cpp, cs, go, ts, js, css` | * **텍스트로 뷰잉 가능 여부**: `.txt`와 동일하게 텍스트 에디터 방식으로 보여줄 수 있으나, 현재 시스템은 개발자용 IDE 환경이 아닌 일반 비즈니스 문서용이므로 코딩 확장자들은 다운로드 전용으로 제한됩니다. |
|
||||||
|
| **대용량 원시 그래픽** | `psd, ai, eps, indd, tiff` | * **독점 그래픽 포맷**: Adobe사 등의 전용 디자인 툴에서 쓰이는 포맷으로, 웹 표준 이미지 태그(`<img>`)가 인식하지 못하며, 클라이언트 단의 JS 파싱 라이브러리가 존재하지 않거나 극도로 불안정합니다. |
|
||||||
|
| **폰트 파일** | `ttf, otf, woff, eot` | * **시스템 자원**: 폰트 데이터는 글꼴 메타데이터의 모음이므로 독립적인 시각적 형태를 브라우저 캔버스 상에 프리뷰할 논리적 근거가 부재합니다. |
|
||||||
BIN
문서뷰어_구현_및_한계대응_분석보고서.pdf
Normal file
BIN
문서뷰어_구현_및_한계대응_분석보고서.pdf
Normal file
Binary file not shown.
67
엑셀파일미리보기도형한계.md
Normal file
67
엑셀파일미리보기도형한계.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 엑셀 파일 미리보기 시 도형(Shapes) 렌더링 한계 분석 및 대응 방안
|
||||||
|
|
||||||
|
본 문서는 웹 브라우저 환경에서 엑셀(`.xlsx`) 파일을 직접 파싱하여 미리보기 화면을 구성할 때, **도형(Shapes, 화살표, 선, 프로세스 차트 등)이 렌더링되지 않는 기술적 원인**과 **한글(HWP) 파일 파싱 방식과의 구조적 차이점**, 그리고 이를 보완하기 위한 **최선의 대응 방안**을 정리한 기술 분석서입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요 및 현상
|
||||||
|
* **정상 작동 영역**: 셀 데이터(텍스트, 숫자, 날짜), 셀 스타일(배경색, 폰트 크기, 테두리), 시트 수식, 셀 병합 상태, 그리고 일부 부유형 이미지(그림 파일)는 브라우저 미리보기 화면에서 정상적으로 표현됩니다.
|
||||||
|
* **비정상 작동 영역**: 사각형, 원형, 화살표, 말풍선, 블록 화살표, 다이어그램, 연결선 등 **엑셀 본문 그리드 위에 독립적으로 배치된 드로잉 도형 객체(Shapes/Drawings)**들은 화면에 아예 표시되지 않거나 누락되는 현상이 발생합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기술적 원인 분석 (오픈소스 라이브러리의 한계)
|
||||||
|
본 시스템은 웹 브라우저 단에서 서버 리소스를 쓰지 않고 빠르게 엑셀을 렌더링하기 위해 다음 두 가지 라이브러리를 결합하여 사용하고 있습니다.
|
||||||
|
1. **LuckyExcel**: 엑셀(`.xlsx` binary/zip) 파일을 자바스크립트 객체(JSON) 데이터 모델로 변환해주는 파서.
|
||||||
|
2. **Luckysheet**: 변환된 JSON 모델을 바탕으로 웹 브라우저 화면에 엑셀 시트 그리드를 그려주고 셀 동작을 처리해주는 렌더러.
|
||||||
|
|
||||||
|
이 두 오픈소스 라이브러리는 태생적으로 **"셀(Cell) 중심의 그리드 표현"**에 특화되어 개발되었기 때문에 다음과 같은 한계를 가집니다.
|
||||||
|
|
||||||
|
### ① LuckyExcel (파서) 측면의 한계 (XML 스펙 무시)
|
||||||
|
* 엑셀 파일(`.xlsx`)은 OpenXML 포맷을 따르며, 시트 내에 배치된 도형 정보는 `xl/drawings/drawing1.xml`과 같은 파일 내에 **DrawingML** 규격으로 기록됩니다.
|
||||||
|
* LuckyExcel은 이 XML 파일을 파싱할 때 이미지 객체를 정의하는 `<xdr:pic>` 태그만 선별적으로 추출하여 Luckysheet의 이미지 모델로 넘겨줍니다.
|
||||||
|
* 반면, 일반 도형을 정의하는 **`<xdr:sp>`(Shape)**나 연결선을 정의하는 **`<xdr:cxnSp>`(Connection Shape)** 등 기타 드로잉 엘리먼트들은 **코드 레벨에서 파싱을 전혀 지원하지 않고 무시**합니다.
|
||||||
|
|
||||||
|
### ② Luckysheet (렌더러) 측면의 한계 (드로잉 모델 부재)
|
||||||
|
* Luckysheet의 내부 데이터 스키마에는 셀 서식, 수식, 그리고 부유형 이미지를 관리하는 속성(`images`)은 설계되어 있으나, 다각형이나 선, 벡터 그래픽을 추상화하여 저장하고 실시간으로 그려주는 **도형 전용 데이터 규격 및 컴포넌트(Drawing Engine)가 존재하지 않습니다.**
|
||||||
|
* 이미지는 웹 브라우저가 기본적으로 지원하는 `<img>` 태그를 절대 좌표 기반으로 얹어주면 비교적 단순하게 렌더링할 수 있지만, 벡터 도형은 브라우저 상에서 가변적으로 그리기가 매우 까다로워 처음부터 설계 범위에서 배제되었습니다.
|
||||||
|
* **추가 이슈**: 현재 Luckysheet 프로젝트는 공식적으로 개발이 중단(Unmaintained)되었으며, 후속 아키텍처 프로젝트인 Univer로 이전이 권장되고 있어 향후에도 도형 렌더링 기능이 보완될 가능성이 없습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 한글(HWP) 파일 파싱 방식과의 차이점
|
||||||
|
"한글 파일은 도형이 나오는데 엑셀은 왜 구현이 불가능한가?"에 대한 구조적 비교는 다음과 같습니다.
|
||||||
|
|
||||||
|
| 비교 항목 | 한글(HWP) 미리보기 파싱 | 엑셀(Excel) 미리보기 파싱 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **사용 라이브러리** | `hwp.js` (자체 커스텀 버전) | `LuckyExcel` + `Luckysheet` (외부 CDN 주입) |
|
||||||
|
| **도형 데이터 모델** | HWP 스펙에 정의된 Line, Rectangle, Ellipse 객체 모델을 내부에 명확히 파싱하여 보유함 | 데이터 모델(JSON) 상에 도형에 대한 정보(좌표, 타입, 스타일 등)를 담을 필드 자체가 없음 |
|
||||||
|
| **렌더링 방식** | 추출된 도형 노드를 브라우저 화면상의 **HTML5 SVG(Scalable Vector Graphics)**로 직접 변환하여 문서 플로우 내에 동적으로 삽입 및 드로잉 처리함 | 시트 좌표계에 독립적인 드로잉 레이어나 SVG 변환 규칙이 존재하지 않으며, 오직 Canvas 표 위에 텍스트/셀 테두리만 출력함 |
|
||||||
|
| **개선 가능 범위** | 라이브러리 소스 코드가 프로젝트 내(`libs/hwp.js`)에 패키징되어 있어 좌표 보정이나 텍스트 누락 방어 등의 수정이 유연하게 가능함 | 외부 라이브러리이며, 도형 표현을 위해선 파서와 렌더링 캔버스 엔진을 바닥부터 다시 코딩해야 하므로 프로젝트 규모의 프레임워크 재설계가 필요함 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 직접 구현 시의 기술적 난제 (CAD/웹 오피스 수준의 공수)
|
||||||
|
서버 도움 없이 클라이언트 브라우저 단에서 엑셀 도형을 직접 그리려면 다음과 같은 고난도의 기술적 해결책을 직접 만들어야 합니다.
|
||||||
|
|
||||||
|
1. **DrawingML Parser 개발**:
|
||||||
|
`JSZip`으로 엑셀 파일 내의 `xl/drawings/drawingX.xml`을 압축 해제하고 DOMParser로 열어 각 도형의 크기, 회전각, 선 스타일, 그라데이션, 정렬, 텍스트 상자 등을 파싱하는 코드를 개발해야 합니다.
|
||||||
|
2. **동적 픽셀 좌표(Dynamic Cell-to-Pixel) 매핑 엔진**:
|
||||||
|
* 엑셀에서 도형 위치는 절대 픽셀이 아니라, 특정 셀을 기준으로 한 위치인 **"Two Cell Anchor"** 구조로 관리됩니다.
|
||||||
|
* *예: "C열 4번째 행의 왼쪽으로부터 10px, 위로부터 5px 떨어진 곳에서 시작하여 E열 8번째 행까지..."*
|
||||||
|
* 따라서 엑셀 시트 내의 열 너비(Column Width)와 행 높이(Row Height)의 동적 변화를 실시간으로 가산하여 정확한 웹 픽셀(X, Y, Width, Height)로 치환해야 합니다.
|
||||||
|
3. **스크롤, 확대/축소 및 열 너비 조절 동기화**:
|
||||||
|
* 사용자가 그리드를 마우스로 조작(스크롤, 컬럼 너비 리사이즈, 시트 확대/축소)할 때마다 SVG 도형 객체의 크기와 좌표도 실시간 재계산 및 Repositioning 처리를 해야 하며, 싱크가 1ms라도 어긋나면 도형이 표 위에 둥둥 떠다니는 왜곡 현상이 발생합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프로젝트 내 조치 내용 및 우회 해결 방법 (Best Practice)
|
||||||
|
웹 브라우저의 프론트엔드 파싱만으로는 엑셀 본래의 완벽한 폰트, 다이어그램, 차트 및 수많은 도형 레이아웃을 100% 보존하여 출력할 수 없습니다.
|
||||||
|
|
||||||
|
이 때문에 업계 표준(Google Docs, Slack, Confluence 등)에 맞춰 **서버 측 고성능 오피스 변환 엔진**을 이용한 **PDF 뷰어 폴백(Fallback) 방식을 조합하여 최선의 UX를 제공**하도록 처리했습니다.
|
||||||
|
|
||||||
|
### 적용된 우회 해결책
|
||||||
|
* **"PDF로 보기" 버튼 상시 노출 처리 (2026-06-19 패치)**:
|
||||||
|
* 기존에는 엑셀 파싱 에러(포맷 불일치 등)가 났을 경우에만 "PDF로 보기" 버튼이 활성화되어, 정상적으로 시트가 열린 상태에서 도형이 깨져 보이는 문서는 원본을 열람할 수 없었습니다.
|
||||||
|
* 엑셀 뷰어가 실행되는 즉시 화면 상단 우측에 **"PDF로 보기"** 버튼이 항상 노출되도록 3개 뷰어 모듈(`pageRenderer.js`, `docPageRenderer.js`, `popup.js`)을 개선하였습니다.
|
||||||
|
* 사용자는 도형 및 완벽한 레이아웃 보존이 필요한 경우 **"PDF로 보기"** 버튼을 눌러 서버가 변환한 정밀한 PDF 뷰어 화면으로 즉시 스위칭하여 원하는 내용을 고화질로 확인할 수 있습니다.
|
||||||
438
한글파일미리보기구현.md
438
한글파일미리보기구현.md
@@ -1,4 +1,4 @@
|
|||||||
# 한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서
|
# 한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서 (고도화 버전)
|
||||||
|
|
||||||
본 문서는 서버 자원 소모 없이 웹 브라우저(클라이언트) 단에서 `.hwp` 파일을 직접 파싱하고 안정적으로 이미지를 비롯한 도형 요소까지 화면에 렌더링하도록 커스텀 반영한 작업 내용, 구현 로직, 아키텍처 및 코드 명세를 다룹니다.
|
본 문서는 서버 자원 소모 없이 웹 브라우저(클라이언트) 단에서 `.hwp` 파일을 직접 파싱하고 안정적으로 이미지를 비롯한 도형 요소까지 화면에 렌더링하도록 커스텀 반영한 작업 내용, 구현 로직, 아키텍처 및 코드 명세를 다룹니다.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 1. 미리보기 아키텍처 및 데이터 흐름
|
## 1. 미리보기 아키텍처 및 데이터 흐름
|
||||||
|
|
||||||
클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 흐름도입니다.
|
클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 전체적인 흐름입니다.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
@@ -14,49 +14,90 @@ graph TD
|
|||||||
B --> C["hwp.js Viewer 초기화"]
|
B --> C["hwp.js Viewer 초기화"]
|
||||||
C --> D["OLE Compound 파일 구조 해석"]
|
C --> D["OLE Compound 파일 구조 해석"]
|
||||||
D --> E["BinData 가상 폴더 내 이미지 파싱"]
|
D --> E["BinData 가상 폴더 내 이미지 파싱"]
|
||||||
E --> F{"Magic Number 분석 (Raw 이미지 여부)"}
|
E --> F{"Magic Number 분석\nRaw 이미지 여부 판별"}
|
||||||
F -->|"PNG/JPEG/GIF/WMF 헤더 일치"| G["Decompress 건너뛰기 (Raw 복사)"]
|
F -->|"PNG/JPEG/GIF/WMF 헤더 일치"| G["Decompress 건너뛰기\nRaw 복사"]
|
||||||
F -->|"헤더 불일치 (압축 상태)"| H["pako.inflate (윈도우 비트 -15) 실행"]
|
F -->|"헤더 불일치 - 압축 상태"| H["pako.inflate 실행\nwindowBits: -15"]
|
||||||
H -->|"실패 시"| I["표준 inflate / raw / 원본 복사 폴백"]
|
H -->|"실패 시"| I["표준 inflate / raw / 원본 복사 폴백"]
|
||||||
G --> J["바이너리 데이터 Uint8Array 래핑"]
|
G --> J["바이너리 데이터 Uint8Array 래핑"]
|
||||||
I --> J
|
I --> J
|
||||||
J --> K["Blob 객체 및 URL.createObjectURL 주소 생성"]
|
J --> K["Blob 객체 및 URL.createObjectURL 생성"]
|
||||||
K --> L{"컨트롤 타입 (control.type) 판별"}
|
K --> L{"control.type 판별"}
|
||||||
L -->|"그림 (Picture)"| M["shapeGroup div에 backgroundImage 매핑"]
|
L -->|"그림 Picture"| M{"확장자 판별"}
|
||||||
L -->|"도형 (Rectangle/Ellipse/Line/Polygon/Arc/Curve)"| N["도형 테두리 선, 둥글기, 연한 배경색 스타일 및 텍스트 렌더링"]
|
M -->|"일반 이미지 PNG/JPG"| M1["shapeGroup div에\nbackgroundImage 매핑"]
|
||||||
M --> O["최종 웹 뷰어 화면 렌더링"]
|
M -->|"벡터 이미지 WMF"| M2["wmf.js Canvas 렌더링 후\nPNG 변환 바인딩"]
|
||||||
|
L -->|"도형 Rectangle/Ellipse/Line 등"| N["shapeGroup 내 SVG 배경 레이어 삽입\nrect, ellipse, line 요소 매핑"]
|
||||||
|
M1 --> O["최종 웹 뷰어 화면 렌더링"]
|
||||||
|
M2 --> O
|
||||||
N --> O
|
N --> O
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 해결한 핵심 이슈 및 작업 내용
|
## 2. 해결한 핵심 이슈 및 작업 내용
|
||||||
|
|
||||||
### 2.1 OLE 이미지 압축 해제(Decompression) 안정화 및 우회 로직
|
### 2.1 OLE 이미지 압축 해제(Decompression) 안정화 및 우회 로직
|
||||||
* **현상:** 구형 HWP 파서 라이브러리(`hwp.js`)는 `pako.inflate`를 수행할 때 윈도우 비트 옵션(`{ windowBits: -15 }`)을 무조건적으로 주입해 압축을 해제했습니다. 그러나 한글 문서가 압축 없이 저장되었거나 일부 이미지가 무압축 Raw 바이너리(PNG, JPEG 등)로 OLE 스트림에 기록된 경우, 해제 오류로 인해 뷰어가 크래시되거나 이미지가 유실(0바이트)되는 문제가 있었습니다.
|
* **현상**: 기존 HWP 파서 라이브러리(`hwp.js`)는 `pako.inflate`를 수행할 때 윈도우 비트 옵션(`{ windowBits: -15 }`)을 무조건적으로 주입해 압축을 해제했습니다. 그러나 한글 문서가 압축 없이 저장되었거나 일부 이미지가 무압축 Raw 바이너리(PNG, JPEG 등)로 OLE 스트림에 기록된 경우, 해제 오류로 인해 뷰어가 크래시되거나 이미지가 유실(0바이트)되는 문제가 있었습니다.
|
||||||
* **조치:**
|
* **조치**:
|
||||||
1. 압축 해제 전 바이너리의 **첫 4바이트(Magic Number) 시그니처**를 대조하여 웹 표준 파일 유형(PNG, JPEG, GIF, WMF) 검사를 선행합니다.
|
1. 압축 해제 전 바이너리의 **첫 4바이트(Magic Number) 시그니처**를 대조하여 웹 표준 파일 유형(PNG, JPEG, GIF, WMF) 검사를 선행합니다.
|
||||||
2. 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능과 안정성을 향상했습니다.
|
2. 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능 및 안정성을 향상했습니다.
|
||||||
3. 압축이 필요한 경우 3단계 예외 처리 폴백(`windowBits: -15` -> `표준 inflate` -> `inflateRaw` -> `원본 바이너리`)을 구축하여 어떠한 조건에서도 파싱이 멈추지 않도록 조치했습니다.
|
3. 압축이 필요한 경우 3단계 예외 처리 폴백(`windowBits: -15` -> `표준 inflate` -> `inflateRaw` -> `원본 바이너리`)을 구축하여 어떠한 조건에서도 파싱이 멈추지 않도록 조치했습니다.
|
||||||
|
|
||||||
### 2.2 MIME 타입 오류 수정 및 바이너리 안전성 보장
|
### 2.2 MIME 타입 오류 수정 및 바이너리 안전성 보장
|
||||||
* **현상:** 기존 뷰어 소스 코드에 `type: "images/".concat(extension)`이라는 치명적인 오타가 있었습니다. 브라우저는 `images/png`와 같은 비표준 MIME 타입을 이해하지 못해 이미지 Blob URL을 백그라운드로 로드하려 할 때 엑스박스나 투명 빈 공간으로 처리(이미지 사라짐 현상)했습니다. 또한, 브라우저 환경에 따라 OLE 파서가 원본을 일반 Array 형태로 리턴할 때 문자열로 깨지는 위험이 존재했습니다.
|
* **현상**: 기존 뷰어 소스 코드에 `type: "images/".concat(extension)`이라는 치명적인 오타가 있었습니다. 브라우저는 `images/png`와 같은 비표준 MIME 타입을 이해하지 못해 이미지 Blob URL을 백그라운드로 로드하려 할 때 엑스박스나 투명 빈 공간으로 처리(이미지 사라짐 현상)했습니다. 또한, 브라우저 환경에 따라 OLE 파서가 원본을 일반 Array 형태로 리턴할 때 문자열로 깨지는 위험이 존재했습니다.
|
||||||
* **조치:**
|
* **조치**:
|
||||||
1. MIME 타입을 표준 규격에 맞게 `"image/".concat(extension)`으로 전면 변경하고, `jpg` 확장자는 브라우저 표준 명칭인 `image/jpeg`로 정확히 매핑했습니다.
|
1. MIME 타입을 표준 규격에 맞게 `"image/".concat(extension)`으로 전면 변경하고, `jpg` 확장자는 브라우저 표준 명칭인 `image/jpeg`로 정확히 매핑했습니다.
|
||||||
2. Blob 생성 시 바이트의 깨짐을 방지하기 위해 생성자 주입 전 데이터 타입을 검증하고 `Uint8Array` 인스턴스로 안전하게 래핑하도록 보장했습니다.
|
2. Blob 생성 시 바이트의 깨짐을 방지하기 위해 생성자 주입 전 데이터 타입을 검증하고 `Uint8Array` 인스턴스로 안전하게 래핑하도록 보장했습니다.
|
||||||
|
|
||||||
### 2.3 한글 문서 자체 생성 도형(Shape Object) 렌더링 기능 추가
|
### 2.3 한글 문서 자체 생성 도형(Shape Object) 렌더링의 SVG 배경 레이어 적용 및 위치 정합성 교정
|
||||||
* **현상:** 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 **직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되었습니다. 이로 인해 테두리와 사각형 배경 없이 글자만 겹쳐서 표시되는 문제가 있었습니다.
|
* **현상**: 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 **직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되거나, 상대 배치 마진 누적으로 인해 본문 뒤로 엉뚱하게 밀려나서 페이지 바깥의 회색 프레임에 잘못 렌더링되는 문제가 있었습니다.
|
||||||
* **조치:**
|
* **조치**:
|
||||||
1. HWP 파서의 핵심 순회 구조(`visit` 스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(`control.type`)를 올바르게 정의하도록 했습니다.
|
1. HWP 파서의 핵심 순회 구조(`visit` 스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(`control.type`)를 올바르게 정의하도록 했습니다.
|
||||||
2. 렌더링 엔진(`drawShape`) 단에서 도형 종류별 CSS 대응(직사각형 외곽선 그리기, 원형 둥글기 `borderRadius: 50%` 처리, 선 굵기 및 정렬, 투명 배경색 지정 등)을 적용하여 시각적으로 구현하고 내부 텍스트와 레이어링이 맞물리도록 개선했습니다.
|
2. 렌더링 엔진(`drawShape`) 단에서 단순 HTML/CSS 스타일로 형태만 구현하던 단독 태그 방식 대신, 글씨 배치가 되는 부모 컨테이너 `div` 내부에 **절대 배치된 `<svg>` 배경 레이어를 생성하여 `<rect>`, `<ellipse>`, `<line>` 요소를 주입**해 벡터 윤곽선을 정밀하게 구현했습니다.
|
||||||
|
3. 도형의 세로 기준 설정(`vertRelTo`) 속성을 해석하여 분기 처리했습니다:
|
||||||
|
- `vertRelTo === 2` (문단 기준): 문단 컨테이너 `div`에 `position: relative` 스타일을 부여하고 도형을 `position: absolute; top: [verticalOffset]; left: [horizontalOffset];`로 정밀 정렬하였습니다.
|
||||||
|
- `vertRelTo === 0 또는 1` (종이/쪽 기준): 도형을 부모 쪽 컨테이너(`container.parentNode`)의 절대 레이어에 직접 덧그려 배치했습니다.
|
||||||
|
- `drawParagraph` 시작 지점에서 문단 `div`를 부모(쪽)에 선제적으로 덧붙여 도형들의 절대 배치 위치 기준(`parentNode`)이 유효하도록 설계했습니다.
|
||||||
|
|
||||||
### 2.4 단락 및 텍스트 줄 간격(Line Spacing) 조절을 통한 겹침 결함 조치
|
### 2.4 단락 및 텍스트 줄 간격(Line Spacing) 조절을 통한 겹침 결함 조치
|
||||||
* **현상:** 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인 `div` 태그의 `line-height` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다.
|
* **현상**: 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인 `div` 태그의 `line-height` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다.
|
||||||
* **조치:**
|
* **조치**:
|
||||||
1. `drawParagraph`를 통해 생성되는 단락 컨테이너뿐만 아니라, 실제 텍스트가 바인딩되는 개별 문자열 `div` 스팬 요소(`drawText` 내의 `span` 객체)에도 **`line-height: 1.65` 인라인 스타일을 강제 적용**했습니다.
|
1. `drawParagraph`를 통해 생성되는 단락 컨테이너뿐만 아니라, 실제 텍스트가 바인딩되는 개별 문자열 `div` 스팬 요소(`drawText` 내의 `span` 객체)에도 **`line-height: 1.65` 인라인 스타일을 강제 적용**했습니다.
|
||||||
2. 인라인 스타일을 직접 지정함으로써 글로벌 스타일시트(CSS) 셀렉터에 의한 오버라이드를 완전 차단하고, 텍스트 줄 간 영역이 서로 침범 및 겹침 현상 없이 한글 표준 규격(160%대)으로 선명하게 공간 배치되도록 수정했습니다.
|
2. 인라인 스타일을 직접 지정함으로써 글로벌 스타일시트(CSS) 셀렉터에 의한 오버라이드를 완전 차단하고, 텍스트 줄 간 영역이 서로 침범 및 겹침 현상 없이 한글 표준 규격(160%대)으로 선명하게 공간 배치되도록 수정했습니다.
|
||||||
|
|
||||||
|
### 2.5 한글 기본 폰트의 웹 폰트(@font-face) 매핑
|
||||||
|
* **현상**: 모바일이나 macOS 등 한글 기본 폰트(바탕체, 돋움체, 굴림체, 궁서체)가 설치되어 있지 않은 클라이언트에서 볼 때 줄바꿈과 표 넓이가 어긋나는 문제가 발생했습니다.
|
||||||
|
* **조치**:
|
||||||
|
1. `views/main/css/font.css`에 구글 Noto Serif KR 및 Noto Sans KR 웹 폰트를 임포트했습니다.
|
||||||
|
2. 각 한글 기본 폰트 이름에 대응하여 로컬 폰트를 우선 선언하고 없으면 Google Web Fonts에서 가져오도록 `@font-face`를 매핑했습니다.
|
||||||
|
|
||||||
|
### 2.6 WMF 벡터 이미지의 클라이언트 캔버스 디코딩 연동
|
||||||
|
* **현상**: 구형 한글 문서에 많이 쓰이는 WMF 포맷은 브라우저 표준 이미지 디코더로 해독할 수 없어 엑스박스로 출력되었습니다.
|
||||||
|
* **조치**:
|
||||||
|
1. SheetJS의 `wmf.js` 라이브러리를 로컬 `libs/wmf.js`에 다운로드하고 `main.html`에 로드하였습니다.
|
||||||
|
2. `drawShape` 실행 시 이미지 확장자가 `wmf`인 경우 `WMF.draw_canvas` API로 메모리 내에 Canvas를 생성해 렌더링한 후, PNG Data URL(`canvas.toDataURL('image/png')`)로 추출해 `backgroundImage`에 바인딩하도록 수정했습니다.
|
||||||
|
|
||||||
|
### 2.7 표 셀(td) padding 지정 및 세로 정렬(vertical-align) 완벽 일치
|
||||||
|
* **현상**: 표 내부의 텍스트가 셀 경계선에 너무 붙어 가독성이 저하되거나, 원본 문서에 지정된 세로 정렬 속성(위, 중간, 아래)이 반영되지 않는 문제가 있었습니다.
|
||||||
|
* **조치**:
|
||||||
|
1. `libs/hwp.js` 내 `visitCellListHeader` 파서에서 셀 버퍼 데이터로부터 **세로 정렬 바이트(vertical-align: 0-상단, 1-중앙, 2-하단)**를 추가로 해독하여 전달하도록 수정했습니다.
|
||||||
|
2. `drawColumn`에서 각 셀의 HWP `padding` 속성을 CSS로 적용하며, 누락되거나 0인 경우에는 웹 표준 기본 패딩(`2px 5px`)이 설정되도록 삼항 조건식을 추가했습니다.
|
||||||
|
3. 해독된 정렬 바이트를 CSS `vertical-align` 속성과 매핑시켜 인라인 스타일에 강제 반영했습니다.
|
||||||
|
|
||||||
|
### 2.8 뷰어 레이아웃 너비 확장 (횡스크롤바 제거)
|
||||||
|
* **현상**: 우측 미리보기 영역 너비가 좁아 A4 Portrait 규격(가로 약 794px + 여백) 문서를 미리볼 때 불필요한 하단 가로 스크롤바가 매번 발생했습니다.
|
||||||
|
* **조치**:
|
||||||
|
1. `views/main/css/style_archive.css`의 우측 미리보기 컨테이너(`.archive-main-right`) 가로 너비를 기존 `41rem`에서 **`55rem` (880px)**으로 확장하여 스크롤바 없는 쾌적한 뷰를 구현했습니다.
|
||||||
|
2. 이에 맞춰 우측 영역과 인접한 토글 스위치 및 버튼 메뉴들의 우측 절대 좌표 위치(`right: 41.5rem` -> **`right: 55.5rem`**)를 상향 조정했습니다.
|
||||||
|
|
||||||
|
### 2.9 HWP 팝업 미리보기 기능 정상화 및 레이아웃 개선
|
||||||
|
* **현상**: 한글 미리보기 전용 팝업창(`/popup/` 경로)에서 WMF 이미지/도형 리소스가 누락되거나, default CJK 폰트 매핑이 존재하지 않아 줄바꿈 및 벡터 도형이 비정상적으로 크고 잘못된 곳에 그려지며 횡스크롤바가 다발하는 문제가 지속되었습니다. 또한 페이지 너비를 강제로 줄일 시 A4 고유 가로폭과 절대 좌표 계산이 무너져 표나 이미지가 찌그러지고 겹치는 왜곡이 발생했습니다.
|
||||||
|
* **조치**:
|
||||||
|
1. `views/main/popup.html` 파일 내부에서 `hwp.js`가 구동되기 전 웹 폰트 매핑 스타일시트(`/main/css/font.css`)와 WMF 벡터 캔버스 렌더러 스크립트(`/libs/wmf.js`)를 반드시 추가 로드하도록 수정했습니다.
|
||||||
|
2. `popup.js`, `pageRenderer.js`, `docPageRenderer.js` 각 한글 미리보기 영역의 동적 CSS 주입(`styleEl.textContent`) 블록 내에 `.hwp-inner-container`의 최대 너비를 **`950px`**로 확장하였습니다.
|
||||||
|
3. 페이지 요소(`div[data-page-number]`)에 일시적으로 인젝션했던 `max-width: 100%` 및 `box-sizing: border-box` 설정을 완전 소거하여, 한글 고유의 A4 정밀 레이아웃 비율이 파괴되거나 표가 깨지는 문제를 방지하고 원래의 정상적인 형태로 복구시켰습니다.
|
||||||
|
4. `.hwpjs-viewer > div` 영역에 `overflow-x: hidden !important` 스타일만을 입혀 불필요한 횡스크롤바의 노출을 제어하고, `libs/hwp.js` 내에서 `vertRelTo === 0` (종이) 및 `1` (쪽) 절대 배치 도형 렌더링 시 부모 페이지의 padding(마진) 값만큼을 좌표에서 빼주어 도형들이 밀려나지 않고 표 바로 밑의 지정된 본문 영역 안에 안착하도록 보정했습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 구현 코드 및 로직 명세 (libs/hwp.js)
|
## 3. 구현 코드 및 로직 명세 (libs/hwp.js)
|
||||||
@@ -91,15 +132,13 @@ value: function visitBinData(record) {
|
|||||||
// GIF: 47 49 46 38
|
// GIF: 47 49 46 38
|
||||||
else if (payload[0] === 0x47 && payload[1] === 0x49 && payload[2] === 0x46 && payload[3] === 0x38) isRawImage = true;
|
else if (payload[0] === 0x47 && payload[1] === 0x49 && payload[2] === 0x46 && payload[3] === 0x38) isRawImage = true;
|
||||||
// WMF: D7 CD C6 9A
|
// WMF: D7 CD C6 9A
|
||||||
else if (payload[0] === 0xD7 && payload[1] === 0xCD && payload[2] === 0xC6 && payload[3] === 0x9A) isRawImage = true;
|
else if (payload[0] === 0x7D && payload[1] === 0xCD && payload[2] === 0xC6 && payload[3] === 0x9A) isRawImage = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var decompressed;
|
var decompressed;
|
||||||
if (isRawImage) {
|
if (isRawImage) {
|
||||||
// 압축 해제 없이 raw 바이너리 복사
|
|
||||||
decompressed = payload;
|
decompressed = payload;
|
||||||
} else {
|
} else {
|
||||||
// 2단계 다중 압축 해제 시도 (zlib 윈도우 비트 -15 -> 표준 -> raw 순서)
|
|
||||||
try {
|
try {
|
||||||
decompressed = pako_1.inflate(payload, { windowBits: -15 });
|
decompressed = pako_1.inflate(payload, { windowBits: -15 });
|
||||||
} catch (e1) {
|
} catch (e1) {
|
||||||
@@ -109,7 +148,7 @@ value: function visitBinData(record) {
|
|||||||
try {
|
try {
|
||||||
decompressed = pako_1.inflateRaw(payload);
|
decompressed = pako_1.inflateRaw(payload);
|
||||||
} catch (e3) {
|
} catch (e3) {
|
||||||
decompressed = payload; // 최종 폴백
|
decompressed = payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,82 +160,106 @@ value: function visitBinData(record) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 도형 태그 식별을 위한 파서 해석 추가 (`visit`)
|
### 3.2 셀 속성 세로 정렬 감지 추가 (`visitCellListHeader`)
|
||||||
|
|
||||||
`switch-case` 블록 내부에 한글 파일 자체 도형 컴포넌트 레코드 식별자를 등록하여 각 도형 오브젝트의 타입 분류를 해석합니다.
|
표 내부 셀(Cell)의 크기 및 패딩에 더하여 세로 정렬(상단/중앙/하단) 옵션의 바이트 코드를 감지하는 구조입니다.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
switch (record.tagID) {
|
key: "visitCellListHeader",
|
||||||
// ... 기존 공통 케이스 ...
|
value: function visitCellListHeader(reader) {
|
||||||
case SectionTagID.HWPTAG_SHAPE_COMPONENT_PICTURE:
|
var option = {
|
||||||
{
|
column: reader.readUInt16(),
|
||||||
this.visitPicture(record, control);
|
row: reader.readUInt16(),
|
||||||
break;
|
colSpan: reader.readUInt16(),
|
||||||
}
|
rowSpan: reader.readUInt16(),
|
||||||
// 추가 반영된 개별 도형 파싱 케이스
|
width: reader.readUInt32(),
|
||||||
case SectionTagID.HWPTAG_SHAPE_COMPONENT_RECTANGLE:
|
height: reader.readUInt32(),
|
||||||
{
|
padding: [reader.readUInt16(), reader.readUInt16(), reader.readUInt16(), reader.readUInt16()]
|
||||||
if (isShape(control)) {
|
};
|
||||||
control.type = CommonCtrlID.Rectangle;
|
|
||||||
}
|
if (!reader.isEOF()) {
|
||||||
break;
|
option.borderFillID = reader.readUInt16() - 1;
|
||||||
}
|
}
|
||||||
case SectionTagID.HWPTAG_SHAPE_COMPONENT_ELLIPSE:
|
|
||||||
{
|
if (!reader.isEOF()) {
|
||||||
if (isShape(control)) {
|
reader.readUInt8(); // text flow 건너뛰기
|
||||||
control.type = CommonCtrlID.Ellipse;
|
}
|
||||||
}
|
|
||||||
break;
|
if (!reader.isEOF()) {
|
||||||
}
|
option.verticalAlign = reader.readUInt8(); // 0: Top, 1: Middle, 2: Bottom
|
||||||
case SectionTagID.HWPTAG_SHAPE_COMPONENT_LINE:
|
}
|
||||||
{
|
|
||||||
if (isShape(control)) {
|
return option;
|
||||||
control.type = CommonCtrlID.Line;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SectionTagID.HWPTAG_SHAPE_COMPONENT_ARC:
|
|
||||||
{
|
|
||||||
if (isShape(control)) {
|
|
||||||
control.type = CommonCtrlID.Arc;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SectionTagID.HWPTAG_SHAPE_COMPONENT_POLYGON:
|
|
||||||
{
|
|
||||||
if (isShape(control)) {
|
|
||||||
control.type = CommonCtrlID.Polygon;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SectionTagID.HWPTAG_SHAPE_COMPONENT_CURVE:
|
|
||||||
{
|
|
||||||
if (isShape(control)) {
|
|
||||||
control.type = CommonCtrlID.Curve;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 이미지 및 도형 요소 스타일 렌더링 (`drawShape`)
|
### 3.3 표 셀 스타일 및 세로 정렬 일치 (`drawColumn`)
|
||||||
|
|
||||||
타입 정보가 명시된 컨트롤을 바탕으로 그림 및 각 도형 컴포넌트를 브라우저에 알맞게 그리는 로직입니다.
|
각 td 셀 요소에 여백 수치를 변환하여 할당하고 세로 정렬 스타일을 적용하는 코드입니다.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
key: "drawColumn",
|
||||||
|
value: function drawColumn(container, paragraphList) {
|
||||||
|
var _this = this;
|
||||||
|
|
||||||
|
var column = document.createElement('td');
|
||||||
|
var attrib = paragraphList.attribute;
|
||||||
|
|
||||||
|
column.style.width = "".concat(attrib.width / 100, "pt");
|
||||||
|
column.style.height = "".concat(attrib.height / 100, "pt");
|
||||||
|
column.colSpan = attrib.colSpan;
|
||||||
|
column.rowSpan = attrib.rowSpan;
|
||||||
|
|
||||||
|
// 여백(padding) 스타일 매핑 & 강제 주입
|
||||||
|
var pad = attrib.padding || [0, 0, 0, 0];
|
||||||
|
var ptLeft = pad[0] > 0 ? (pad[0] / 100) + "pt" : "5px";
|
||||||
|
var ptRight = pad[1] > 0 ? (pad[1] / 100) + "pt" : "5px";
|
||||||
|
var ptTop = pad[2] > 0 ? (pad[2] / 100) + "pt" : "2px";
|
||||||
|
var ptBottom = pad[3] > 0 ? (pad[3] / 100) + "pt" : "2px";
|
||||||
|
column.style.padding = ptTop + " " + ptRight + " " + ptBottom + " " + ptLeft;
|
||||||
|
|
||||||
|
// 세로 정렬(vertical-align) 매핑 (0: top, 1: middle, 2: bottom)
|
||||||
|
var valigns = ['top', 'middle', 'bottom'];
|
||||||
|
column.style.verticalAlign = valigns[attrib.verticalAlign] || 'middle';
|
||||||
|
|
||||||
|
this.drawBorderFill(column, attrib.borderFillID);
|
||||||
|
paragraphList.items.forEach(function (paragraph) {
|
||||||
|
_this.drawParagraph(column, paragraph);
|
||||||
|
});
|
||||||
|
container.appendChild(column);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 이미지 WMF 캔버스 디코딩 및 도형 SVG 렌더링 & 절대 배치 (`drawShape`)
|
||||||
|
|
||||||
|
타입 정보가 명시된 컨트롤을 바탕으로 그림(WMF 대응 포함) 및 각 도형 컴포넌트를 브라우저에 배경 레이어로 알맞게 그리는 로직입니다.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
key: "drawShape",
|
key: "drawShape",
|
||||||
value: function drawShape(container, control) {
|
value: function drawShape(container, control) {
|
||||||
var _this3 = this;
|
var _this3 = this;
|
||||||
|
|
||||||
var shapeGroup = document.createElement('div');
|
var shapeGroup = document.createElement('div');
|
||||||
shapeGroup.style.width = "".concat(control.width / 100, "pt");
|
shapeGroup.style.width = "".concat(control.width / 100, "pt");
|
||||||
shapeGroup.style.height = "".concat(control.height / 100, "pt");
|
shapeGroup.style.height = "".concat(control.height / 100, "pt");
|
||||||
|
|
||||||
// 위치 스타일 세팅 (절대 좌표 및 여백)
|
var targetContainer = container;
|
||||||
if (control.attribute.vertRelTo === 0) {
|
if (control.attribute.vertRelTo === 2) {
|
||||||
|
// 문단(Paragraph) 기준 절대 배치
|
||||||
|
container.style.position = 'relative';
|
||||||
shapeGroup.style.position = 'absolute';
|
shapeGroup.style.position = 'absolute';
|
||||||
shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt");
|
shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt");
|
||||||
shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt");
|
shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt");
|
||||||
|
} else if (control.attribute.vertRelTo === 0 || control.attribute.vertRelTo === 1) {
|
||||||
|
// 종이(Paper) / 쪽(Page) 기준 절대 배치
|
||||||
|
shapeGroup.style.position = 'absolute';
|
||||||
|
shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt");
|
||||||
|
shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt");
|
||||||
|
if (container.parentNode) {
|
||||||
|
targetContainer = container.parentNode;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 문자 단위 등 기타 여백 배치
|
||||||
shapeGroup.style.marginTop = "".concat(control.verticalOffset / 100, "pt");
|
shapeGroup.style.marginTop = "".concat(control.verticalOffset / 100, "pt");
|
||||||
shapeGroup.style.marginLeft = "".concat(control.horizontalOffset / 100, "pt");
|
shapeGroup.style.marginLeft = "".concat(control.horizontalOffset / 100, "pt");
|
||||||
}
|
}
|
||||||
@@ -206,7 +269,6 @@ value: function drawShape(container, control) {
|
|||||||
shapeGroup.style.display = 'inline-block';
|
shapeGroup.style.display = 'inline-block';
|
||||||
|
|
||||||
if (isPicture(control)) {
|
if (isPicture(control)) {
|
||||||
// [1] 이미지(그림) 렌더링 분기
|
|
||||||
var image = this.hwpDocument.info.binData[control.info.binID];
|
var image = this.hwpDocument.info.binData[control.info.binID];
|
||||||
if (!image || !image.payload || image.payload.length === 0) {
|
if (!image || !image.payload || image.payload.length === 0) {
|
||||||
shapeGroup.style.border = '1px dashed #aaaaaa';
|
shapeGroup.style.border = '1px dashed #aaaaaa';
|
||||||
@@ -224,134 +286,110 @@ value: function drawShape(container, control) {
|
|||||||
shapeGroup.appendChild(placeholder);
|
shapeGroup.appendChild(placeholder);
|
||||||
} else {
|
} else {
|
||||||
var uint8Arr = image.payload instanceof Uint8Array ? image.payload : new Uint8Array(image.payload);
|
var uint8Arr = image.payload instanceof Uint8Array ? image.payload : new Uint8Array(image.payload);
|
||||||
var blob = new Blob([uint8Arr], {
|
|
||||||
type: image.extension === 'jpg' ? 'image/jpeg' : "image/".concat(image.extension)
|
|
||||||
});
|
|
||||||
var imageURL = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")");
|
// WMF 디코딩 처리
|
||||||
shapeGroup.style.backgroundRepeat = 'no-repeat';
|
if (image.extension.toLowerCase() === 'wmf' && typeof WMF !== 'undefined') {
|
||||||
shapeGroup.style.backgroundPosition = 'center';
|
try {
|
||||||
shapeGroup.style.backgroundSize = 'contain';
|
var size = WMF.image_size(uint8Arr);
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size[0] || (control.width / 10);
|
||||||
|
canvas.height = size[1] || (control.height / 10);
|
||||||
|
WMF.draw_canvas(uint8Arr, canvas);
|
||||||
|
var imageURL = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
|
shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")");
|
||||||
|
shapeGroup.style.backgroundRepeat = 'no-repeat';
|
||||||
|
shapeGroup.style.backgroundPosition = 'center';
|
||||||
|
shapeGroup.style.backgroundSize = 'contain';
|
||||||
|
} catch (wmfErr) {
|
||||||
|
console.error("[HWP.js Debug] WMF rendering error:", wmfErr);
|
||||||
|
shapeGroup.style.border = '1px solid red';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var blob = new Blob([uint8Arr], {
|
||||||
|
type: image.extension === 'jpg' ? 'image/jpeg' : "image/".concat(image.extension)
|
||||||
|
});
|
||||||
|
|
||||||
|
var imageURL = window.URL.createObjectURL(blob);
|
||||||
|
shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")");
|
||||||
|
shapeGroup.style.backgroundRepeat = 'no-repeat';
|
||||||
|
shapeGroup.style.backgroundPosition = 'center';
|
||||||
|
shapeGroup.style.backgroundSize = 'contain';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// [2] 자체 제작 도형 렌더링 분기
|
|
||||||
shapeGroup.style.boxSizing = 'border-box';
|
shapeGroup.style.boxSizing = 'border-box';
|
||||||
|
shapeGroup.style.position = 'relative';
|
||||||
|
|
||||||
|
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', '100%');
|
||||||
|
svg.setAttribute('height', '100%');
|
||||||
|
svg.style.position = 'absolute';
|
||||||
|
svg.style.top = '0';
|
||||||
|
svg.style.left = '0';
|
||||||
|
svg.style.pointerEvents = 'none';
|
||||||
|
svg.style.zIndex = '-1';
|
||||||
|
|
||||||
if (control.type === CommonCtrlID.Rectangle) {
|
if (control.type === CommonCtrlID.Rectangle) {
|
||||||
// 직사각형 외곽 테두리 및 옅은 배경
|
var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
shapeGroup.style.border = '1.5px solid #333333';
|
rect.setAttribute('x', '1.5');
|
||||||
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.02)';
|
rect.setAttribute('y', '1.5');
|
||||||
|
rect.setAttribute('width', 'calc(100% - 3px)');
|
||||||
|
rect.setAttribute('height', 'calc(100% - 3px)');
|
||||||
|
rect.setAttribute('fill', 'rgba(0, 0, 0, 0.02)');
|
||||||
|
rect.setAttribute('stroke', '#333333');
|
||||||
|
rect.setAttribute('stroke-width', '1.5');
|
||||||
|
svg.appendChild(rect);
|
||||||
} else if (control.type === CommonCtrlID.Ellipse) {
|
} else if (control.type === CommonCtrlID.Ellipse) {
|
||||||
// 타원형 및 둥근 모서리 처리
|
var ellipse = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
|
||||||
shapeGroup.style.border = '1.5px solid #333333';
|
ellipse.setAttribute('cx', '50%');
|
||||||
shapeGroup.style.borderRadius = '50%';
|
ellipse.setAttribute('cy', '50%');
|
||||||
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.02)';
|
ellipse.setAttribute('rx', 'calc(50% - 1.5px)');
|
||||||
|
ellipse.setAttribute('ry', 'calc(50% - 1.5px)');
|
||||||
|
ellipse.setAttribute('fill', 'rgba(0, 0, 0, 0.02)');
|
||||||
|
ellipse.setAttribute('stroke', '#333333');
|
||||||
|
ellipse.setAttribute('stroke-width', '1.5');
|
||||||
|
svg.appendChild(ellipse);
|
||||||
} else if (control.type === CommonCtrlID.Line) {
|
} else if (control.type === CommonCtrlID.Line) {
|
||||||
// 선 객체의 세로/가로 두께 정렬
|
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
var w = control.width / 100;
|
line.setAttribute('x1', '0');
|
||||||
var h = control.height / 100;
|
line.setAttribute('y1', '0');
|
||||||
if (h < 5) {
|
line.setAttribute('x2', '100%');
|
||||||
shapeGroup.style.borderTop = '1.5px solid #333333';
|
line.setAttribute('y2', '100%');
|
||||||
} else if (w < 5) {
|
line.setAttribute('stroke', '#333333');
|
||||||
shapeGroup.style.borderLeft = '1.5px solid #333333';
|
line.setAttribute('stroke-width', '1.5');
|
||||||
} else {
|
svg.appendChild(line);
|
||||||
shapeGroup.style.border = '1px solid #333333';
|
|
||||||
}
|
|
||||||
} else if (control.type === CommonCtrlID.Arc || control.type === CommonCtrlID.Polygon || control.type === CommonCtrlID.Curve) {
|
} else if (control.type === CommonCtrlID.Arc || control.type === CommonCtrlID.Polygon || control.type === CommonCtrlID.Curve) {
|
||||||
// 다각형, 호, 자유곡선의 흐린 테두리 가이드
|
var placeholder = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
shapeGroup.style.border = '1px dashed #555555';
|
placeholder.setAttribute('x', '1');
|
||||||
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.01)';
|
placeholder.setAttribute('y', '1');
|
||||||
|
placeholder.setAttribute('width', 'calc(100% - 2px)');
|
||||||
|
placeholder.setAttribute('height', 'calc(100% - 2px)');
|
||||||
|
placeholder.setAttribute('fill', 'rgba(0, 0, 0, 0.01)');
|
||||||
|
placeholder.setAttribute('stroke', '#555555');
|
||||||
|
placeholder.setAttribute('stroke-width', '1');
|
||||||
|
placeholder.setAttribute('stroke-dasharray', '4');
|
||||||
|
svg.appendChild(placeholder);
|
||||||
} else {
|
} else {
|
||||||
shapeGroup.style.border = '1px solid #cccccc';
|
var defaultRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.01)';
|
defaultRect.setAttribute('x', '1');
|
||||||
|
defaultRect.setAttribute('y', '1');
|
||||||
|
defaultRect.setAttribute('width', 'calc(100% - 2px)');
|
||||||
|
defaultRect.setAttribute('height', 'calc(100% - 2px)');
|
||||||
|
defaultRect.setAttribute('fill', 'rgba(0, 0, 0, 0.01)');
|
||||||
|
defaultRect.setAttribute('stroke', '#cccccc');
|
||||||
|
defaultRect.setAttribute('stroke-width', '1');
|
||||||
|
svg.appendChild(defaultRect);
|
||||||
}
|
}
|
||||||
|
shapeGroup.appendChild(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 도형 내부 Paragraph 텍스트가 정상 레이어링되도록 오버레이 처리
|
|
||||||
control.content.forEach(function (paragraphList) {
|
control.content.forEach(function (paragraphList) {
|
||||||
paragraphList.items.forEach(function (paragraph) {
|
paragraphList.items.forEach(function (paragraph) {
|
||||||
_this3.drawParagraph(shapeGroup, paragraph);
|
_this3.drawParagraph(shapeGroup, paragraph);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
container.appendChild(shapeGroup);
|
targetContainer.appendChild(shapeGroup);
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 개별 텍스트 스팬 스타일 지정 (`drawText`)
|
|
||||||
|
|
||||||
개별 문자열 `div` 스팬 단위로 `line-height`를 인라인 스타일로 직접 주입하여 글로벌 CSS 오버라이드를 해결한 코드입니다.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
key: "drawText",
|
|
||||||
value: function drawText(container, paragraph, shapePointer, endPos) {
|
|
||||||
var _this4 = this;
|
|
||||||
|
|
||||||
var range = paragraph.content.slice(shapePointer.pos, endPos + 1);
|
|
||||||
var texts = [];
|
|
||||||
var ctrlIndex = 0;
|
|
||||||
range.forEach(function (hwpChar) {
|
|
||||||
if (typeof hwpChar.value === 'string') {
|
|
||||||
texts.push(hwpChar.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hwpChar.type === CharType.Extened) {
|
|
||||||
var control = paragraph.controls[ctrlIndex];
|
|
||||||
ctrlIndex += 1;
|
|
||||||
|
|
||||||
_this4.drawControl(container, control);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hwpChar.value === 13) {
|
|
||||||
texts.push('\n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var text = texts.join('');
|
|
||||||
var span = document.createElement('div');
|
|
||||||
span.textContent = text;
|
|
||||||
span.style.lineHeight = '1.65'; // 인라인 줄 간격 직접 부여하여 영역 겹침 현상 원천 차단
|
|
||||||
|
|
||||||
var charShape = this.hwpDocument.info.getCharShpe(shapePointer.shapeIndex);
|
|
||||||
|
|
||||||
if (charShape) {
|
|
||||||
var fontBaseSize = charShape.fontBaseSize,
|
|
||||||
fontRatio = charShape.fontRatio,
|
|
||||||
color = charShape.color,
|
|
||||||
fontId = charShape.fontId;
|
|
||||||
var fontSize = fontBaseSize * (fontRatio[0] / 100);
|
|
||||||
span.style.fontSize = "".concat(fontSize, "pt");
|
|
||||||
span.style.lineBreak = 'anywhere';
|
|
||||||
span.style.whiteSpace = 'pre-wrap';
|
|
||||||
span.style.color = this.getRGBStyle(color);
|
|
||||||
var fontFace = this.hwpDocument.info.fontFaces[fontId[0]];
|
|
||||||
span.style.fontFamily = fontFace.getFontFamily();
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(span);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5 텍스트 단락 줄 간격 지정 (`drawParagraph`)
|
|
||||||
|
|
||||||
단락 컨테이너 생성 시 줄 간격을 쾌적하게(1.65배) 렌더링하는 코드 부분입니다.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
key: "drawParagraph",
|
|
||||||
value: function drawParagraph(container, paragraph) {
|
|
||||||
var _this5 = this;
|
|
||||||
|
|
||||||
var paragraphContainer = document.createElement('div');
|
|
||||||
paragraphContainer.style.margin = '0';
|
|
||||||
paragraphContainer.style.lineHeight = '1.65'; // 부모 단락에도 줄 간격 지정
|
|
||||||
|
|
||||||
var shape = this.hwpDocument.info.paragraphShapes[paragraph.shapeIndex];
|
|
||||||
paragraphContainer.style.textAlign = TEXT_ALIGN[shape.align];
|
|
||||||
paragraph.shapeBuffer.forEach(function (shapePointer, index) {
|
|
||||||
var endPos = paragraph.getShapeEndPos(index);
|
|
||||||
|
|
||||||
_this5.drawText(paragraphContainer, paragraph, shapePointer, endPos);
|
|
||||||
});
|
|
||||||
container.append(paragraphContainer);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -359,8 +397,12 @@ value: function drawParagraph(container, paragraph) {
|
|||||||
|
|
||||||
## 4. 디버깅 및 유지보수 가이드
|
## 4. 디버깅 및 유지보수 가이드
|
||||||
|
|
||||||
1. **클라이언트 캐시 문제:**
|
1. **클라이언트 캐시 문제**:
|
||||||
* 소스 파일인 `hwp.js`가 업데이트된 후에도 브라우저가 이전 라이브러리를 캐싱하고 있다면 이미지/도형이 보이지 않거나 줄 간격이 좁게 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오.
|
* 소스 파일인 `hwp.js`나 스타일시트가 업데이트된 후에도 브라우저가 이전 파일을 캐싱하고 있다면 레이아웃 변경이 보이지 않거나 스크롤이 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오.
|
||||||
2. **WMF/EMF 벡터 포맷 추가 제언:**
|
2. **폰트 로딩**:
|
||||||
* 현재 웹 표준 규격(`PNG`, `JPEG`, `GIF`) 이미지는 프론트엔드 내에서 완전하게 디코딩됩니다.
|
* CDN을 통해 가져오는 구글 Noto 웹 폰트가 차단된 특수 오프라인(망분리) 환경인 경우, 폰트 파일(`woff2`)을 로컬 서버 `views/main/css/font/` 경로 등에 직접 배치하고 `font.css` 파일에서 경로명을 상대경로로 기입하여 로딩해야 합니다.
|
||||||
* 향후 다수의 WMF/EMF 포맷에 대한 정교한 벡터 렌더링이 필요한 경우, `wmf.js` 라이브러리를 연동하거나, 가이드 3절의 백엔드 경량 이미지 변환 API(`magick convert` 등)를 통해 웹 표준 이미지(`WebP`/`PNG`)로 변환하여 렌더링하도록 확장 대응이 가능합니다.
|
3. **가로/세로 혼용 문서의 가로 스크롤 조치**:
|
||||||
|
* 구역 설정으로 인해 세로형 문서 중간에 가로 방향(Landscape) 페이지가 포함될 경우, 해당 가로 페이지는 구역 크기에 비례하여 화면에 훨씬 넓게 그려집니다.
|
||||||
|
* 기본적으로 뷰어 컨테이너에 `overflow: auto`가 설정되어 가로 스크롤바가 자동 제공되나, 모바일 뷰 등을 고려하여 한 화면에 맞추는 기능이 요구될 경우 JavaScript 단에서 `transform: scale(비율)` 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
136
한글파일미리보기기능의 한계 및 제안.md
Normal file
136
한글파일미리보기기능의 한계 및 제안.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
|
||||||
|
## 1. 기능 한계 원인 분석 및 최선 대응 방안
|
||||||
|
|
||||||
|
본 섹션은 클라이언트 사이드 HWP 미리보기가 원본 문서와 완전히 일치하지 못하는 **구조적·기술적 원인**을 분석하고, 각 한계에 대해 현실적으로 취할 수 있는 **최선의 대응 방안**을 정리합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.1 한계의 근본 원인: HWP 포맷의 비공개 독점 규격
|
||||||
|
|
||||||
|
HWP(한글과컴퓨터 워드프로세서) 파일 포맷은 **공개된 국제 표준(ISO/IEC)이 아닌 한컴의 독점 바이너리 규격**입니다.
|
||||||
|
|
||||||
|
* **공개 문서의 한계**: 한컴은 일부 규격 문서(`HWP 5.0 File Format Specification`)를 공개했으나, 이는 전체 규격의 약 60~70% 수준이며 도형(Shape) 세부 속성, 필드 코드, 고급 서식 등 대부분의 복잡한 요소는 문서화되지 않았습니다.
|
||||||
|
* **버전별 파편화**: HWP 파일은 버전(97/2002/2007/2010/2022 등)마다 내부 구조가 다르며, 버전 간 호환성 처리가 매우 복잡합니다.
|
||||||
|
* **오픈소스 파서의 한계**: 현재 사용 중인 `hwp.js`(버전 0.0.3)는 커뮤니티 기반의 역공학(Reverse Engineering) 결과물로, 기본적인 텍스트·표·이미지만 처리하며 고급 기능 구현이 부재합니다.
|
||||||
|
|
||||||
|
> **결론**: 브라우저 단에서 네이티브 한글 프로그램 수준의 완벽한 재현은 현재 기술로는 구조적으로 불가능합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 항목별 한계 원인 및 대응 방안
|
||||||
|
|
||||||
|
#### ① 도형(Shape) 위치 및 크기 오차
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **한계** | HWP 도형의 절대 좌표는 `vertRelTo`(세로 기준), `horzRelTo`(가로 기준) 등 복합적인 기준점 체계로 계산되며, 기준점이 문단·쪽·종이·단·표·셀 등 9가지 이상. 이 모든 경우를 역공학으로 완전 파악하기 불가능 |
|
||||||
|
| **현재 대응** | `vertRelTo` 0(종이), 1(쪽), 2(문단) 3가지 케이스만 구현. DOM 트리 상위 탐색으로 실제 페이지 요소 발굴 후 좌표 배치 |
|
||||||
|
| **최선 대응** | ① 현재 구현 유지(오차는 있으나 대략적 위치는 맞음) ② 도형이 매우 중요한 경우 **서버 사이드 변환(LibreOffice → PDF)** 방식으로 전환 |
|
||||||
|
|
||||||
|
#### ② 선(Line) 도형 방향 오차
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **한계** | HWP Line 도형은 bounding box 안에서의 정확한 시작점(x1,y1)·끝점(x2,y2) 좌표를 별도 payload에 저장하는데, `hwp.js` 파서는 이 payload를 파싱하지 않음 |
|
||||||
|
| **현재 대응** | bounding box의 가로/세로 비율(aspect ratio)로 방향을 **추론**: 비율 < 0.15 → 수평선, 비율 > 6.5 → 수직선, 그 외 → 대각선 |
|
||||||
|
| **최선 대응** | ① 현재 추론 방식 유지 (대부분의 실용적 케이스 커버) ② `hwp.js` 파서를 포크(fork)하여 `ShapeComponent` payload의 `startX/Y`, `endX/Y` 필드를 직접 파싱하는 고도화 개발 |
|
||||||
|
|
||||||
|
#### ③ 이미지(Picture) 렌더링 실패 시 텍스트 유실
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **한계** | HWP 문서에서 이미지와 텍스트가 같은 문단(Paragraph)에 혼재할 때, 이미지 파싱 실패 시 JavaScript `forEach` 루프가 중단되어 이후 텍스트가 통째로 누락 |
|
||||||
|
| **현재 대응** | `drawControl()` 호출을 `try-catch`로 감싸 오류를 무시하고 나머지 텍스트를 계속 처리하도록 방어 코드 추가 |
|
||||||
|
| **최선 대응** | ① 현재 방어 코드 유지 ② 이미지 로드 실패 시 `[그림 영역]` placeholder를 표시하여 레이아웃 유지 |
|
||||||
|
|
||||||
|
#### ④ WMF/EMF 벡터 이미지 렌더링 품질
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **한계** | WMF(Windows Metafile)는 브라우저 표준 이미지 포맷이 아닌 Windows 전용 벡터 포맷. `wmf.js`로 Canvas 변환하면 일부 GDI 명령어가 누락되거나 색상·선 굵기가 다르게 표현됨 |
|
||||||
|
| **현재 대응** | SheetJS의 `wmf.js`로 Canvas 렌더링 후 PNG DataURL로 변환하여 `backgroundImage`에 바인딩 |
|
||||||
|
| **최선 대응** | ① 현재 방식 유지 ② WMF가 매우 중요한 경우 서버에서 `LibreOffice` 또는 `ImageMagick`으로 SVG/PNG 변환 후 제공 |
|
||||||
|
|
||||||
|
#### ⑤ 복잡한 표(Table) 레이아웃 재현 한계
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **한계** | HWP 표의 셀 병합(merge), 대각선 테두리, 배경 그라데이션, 표 캡션, 표 안의 표(nested table) 등 고급 서식이 파서에서 누락됨 |
|
||||||
|
| **현재 대응** | 기본 셀 너비/높이/패딩/테두리/세로정렬을 파싱하여 `<table><tr><td>` 구조로 렌더링 |
|
||||||
|
| **최선 대응** | ① 현재 방식으로 대부분의 단순 표는 재현 가능 ② 복잡한 표 서식이 필수인 경우 서버 사이드 변환 방식 권고 |
|
||||||
|
|
||||||
|
#### ⑥ 폰트 장평(Font Metrics) 불일치로 인한 줄바꿈 차이
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **한계** | 한글 문서의 줄바꿈은 한컴 전용 폰트 렌더러의 글자 너비 계산값 기준. 웹 폰트(Noto KR 등)는 같은 포인트 크기라도 글자 너비가 미세하게 달라 줄바꿈 위치가 틀어짐 |
|
||||||
|
| **현재 대응** | `@font-face`로 한글 기본 폰트(바탕, 돋움 등)를 Noto KR 웹 폰트에 매핑하여 최대한 근접하게 표시 |
|
||||||
|
| **최선 대응** | ① 서버에 한컴 폰트(HCR 폰트 등)를 woff2로 변환하여 직접 서빙 ② 완벽한 재현이 필요하면 서버 사이드 PDF 변환 방식 필수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 대응 방식 비교: 클라이언트 사이드 vs 서버 사이드
|
||||||
|
|
||||||
|
현재 구현은 **클라이언트 사이드** 방식입니다. 아래는 두 방식의 장단점 비교입니다.
|
||||||
|
|
||||||
|
| 항목 | 클라이언트 사이드 (현재) | 서버 사이드 (LibreOffice/PDF 변환) |
|
||||||
|
|---|---|---|
|
||||||
|
| **재현 정확도** | ★★☆☆☆ (기본 요소만 재현) | ★★★★☆ (원본에 매우 근접) |
|
||||||
|
| **서버 부하** | 없음 (브라우저가 처리) | 높음 (변환 프로세스 실행) |
|
||||||
|
| **응답 속도** | 빠름 (별도 변환 없음) | 느림 (변환 후 전달) |
|
||||||
|
| **오프라인 지원** | 가능 | 불가 |
|
||||||
|
| **유지보수 비용** | 높음 (파서 직접 관리) | 낮음 (LibreOffice가 처리) |
|
||||||
|
| **도형/수식/고급서식** | ❌ 미지원 | ✅ 대부분 지원 |
|
||||||
|
| **서버 요구사항** | 없음 | LibreOffice 설치 필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 최선 대응 전략 권고
|
||||||
|
|
||||||
|
현재 PM 시스템의 **HWP 미리보기 목적**(`첨부 파일의 대략적인 내용 확인`)을 고려한 권고안입니다.
|
||||||
|
|
||||||
|
#### 전략 A: 현재 방식 유지 + 점진적 개선 (권고)
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 클라이언트 사이드 방식 유지
|
||||||
|
├─ 텍스트/표/이미지 기본 재현 가능
|
||||||
|
├─ 서버 부하 없음
|
||||||
|
├─ 복잡한 도형/수식 → "[그림 영역]" placeholder로 표시
|
||||||
|
└─ 사용자에게 "정확한 확인은 원본 다운로드" 안내 제공
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 전략 B: 하이브리드 방식 (장기 권고)
|
||||||
|
|
||||||
|
```
|
||||||
|
1차 시도: 클라이언트 사이드 hwp.js 렌더링 (빠름)
|
||||||
|
↓ 렌더링 오류 또는 복잡한 문서 감지 시
|
||||||
|
2차 폴백: "PDF로 보기" 버튼으로 서버 사이드 변환 안내
|
||||||
|
↓ 서버에서 LibreOffice를 통해 HWP → PDF 변환
|
||||||
|
PDF 뷰어로 고품질 렌더링 제공
|
||||||
|
```
|
||||||
|
|
||||||
|
> 현재 시스템에는 이미 **"PDF로 보기"** 폴백 버튼이 구현되어 있어 전략 B의 기반은 갖춰진 상태입니다.
|
||||||
|
|
||||||
|
#### 전략 C: 완전 서버 사이드 전환 (고정밀도 요구 시)
|
||||||
|
|
||||||
|
```
|
||||||
|
HWP 파일 업로드
|
||||||
|
→ 서버(LibreOffice headless)에서 HWP → PDF/SVG 변환
|
||||||
|
→ 변환된 PDF를 PDF.js로 고품질 렌더링
|
||||||
|
```
|
||||||
|
|
||||||
|
* **장점**: 원본 문서와 가장 높은 일치율 보장
|
||||||
|
* **단점**: 서버에 LibreOffice 설치 및 운영 필요, 변환 시간 소요(파일당 2~10초)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 사용자 안내 메시지 권고
|
||||||
|
|
||||||
|
미리보기와 원본 문서 간 차이가 있을 수 있음을 사용자에게 명확히 안내하는 것이 중요합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
💡 한글 문서(.hwp) 미리보기는 브라우저에서 직접 렌더링하는 방식으로,
|
||||||
|
복잡한 도형·수식·특수 서식은 원본과 다르게 표시될 수 있습니다.
|
||||||
|
정확한 내용 확인이 필요한 경우 [파일 다운로드] 또는 [PDF로 보기]를 이용해 주세요.
|
||||||
|
```
|
||||||
|
|
||||||
117
한글파일미리보기작업내역.md
Normal file
117
한글파일미리보기작업내역.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 한글 파일(HWP) 미리보기 렌더링 고도화 작업 내역서
|
||||||
|
|
||||||
|
본 문서는 웹 브라우저(클라이언트) 환경에서 한글 문서(`.hwp`) 파일을 직접 파싱하고 렌더링하는 클라이언트 사이드 미리보기 엔진의 고도화 작업 및 디버깅 수행 내역을 상세히 기록한 문서입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 주요 작업
|
||||||
|
|
||||||
|
클라이언트 뷰어 라이브러리(`libs/hwp.js`) 및 스타일시트, HTML 로드 방식을 개선하여 원본 문서와의 레이아웃 일치율을 비약적으로 끌어올리고 깨진 리소스(이미지/도형)를 복구하는 것을 목표로 하였습니다.
|
||||||
|
|
||||||
|
* **바이너리 압축 해제 및 이미지 MIME 안정화**: 무압축 Raw 이미지 파싱 우회 및 다단계 압축해제 예외처리 폴백 구축, 오타로 인한 이미지 로딩 에러(images/png) 수정.
|
||||||
|
* **텍스트 영역 겹침 및 간격 보정**: 글로벌 CSS 충돌로 인한 텍스트 줄 겹침 결함을 단락 및 스팬 인라인 스타일 강제 부여를 통해 보정.
|
||||||
|
* **HWP 문서 내 자체 생성 도형(벡터) 복원 및 위치 정밀도 교정**:
|
||||||
|
- 파싱 대상에서 유실되었던 직사각형, 타원, 선 등의 형상을 SVG 배경 레이어 렌더러로 복구.
|
||||||
|
- 도형의 기준 위치(`vertRelTo` 세로 기준: 문단/쪽/종이)에 따라 절대 배치(`position: absolute`) 및 좌표 배치를 분기하여, 도형이 정상 범위를 벗어나 페이지 바깥(회색 배경 부분)으로 밀려 나가는 겹침 오류를 완벽히 수정.
|
||||||
|
* **표(Table) 스타일 및 정렬 정밀 매핑**: 셀 여백(Padding) 스타일 강제 적용 및 HWP 바이너리에 포함된 세로 정렬(Vertical Alignment) 속성을 CSS 속성에 1대1 일치화.
|
||||||
|
* **한글 기본 폰트 대체 매핑**: 클라이언트 기기에 한양굴림, 한컴바탕 등 오피스 전용 폰트가 설치되어 있지 않을 경우를 대비하여 웹 폰트(Noto Serif/Sans KR)를 연동.
|
||||||
|
* **WMF 벡터 파일 캔버스 디코딩 연동**: 브라우저 표준 디코더가 읽지 못하는 `.wmf` 벡터 그래픽 파일을 클라이언트 단에서 파싱하여 PNG 이미지로 변환 및 렌더링.
|
||||||
|
* **뷰어 레이아웃 너비 확장 (가로 스크롤 제거)**:
|
||||||
|
- 기본 가로 너비가 좁아 A4 문서 미리보기 시 발생하던 불필요한 가로(횡) 스크롤바를 원천 제거하기 위해 우측 미리보기 영역 너비를 확장.
|
||||||
|
* **팝업 미리보기 창 라이브러리/스타일셋 동기화**:
|
||||||
|
- 별도 팝업 창(`views/main/popup.html`)에서 한글 미리보기를 열 때 `wmf.js`와 `font.css` 로드 누락으로 인해 도형이 깨지거나 횡스크롤바가 발생하던 문제를 리소스 태그 삽입 및 인라인/클래스 스타일링 규격화로 정정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 수행 내용
|
||||||
|
|
||||||
|
수행된 작업의 구체적인 파일별 수정 상세 명세입니다.
|
||||||
|
|
||||||
|
### 2.1 폰트 정의 추가 및 웹 폰트 매핑
|
||||||
|
* **대상 파일**: [font.css](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/css/font.css)
|
||||||
|
* **내용**:
|
||||||
|
- 구글 Noto Sans KR 및 Noto Serif KR 웹 폰트를 CSS 임포트하고 폰트 매핑을 정의했습니다.
|
||||||
|
|
||||||
|
### 2.2 WMF 디코더 스크립트 로드 추가
|
||||||
|
* **대상 파일**: [main.html](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/main.html)
|
||||||
|
* **내용**:
|
||||||
|
- `libs/hwp.js`가 로딩되기 전 `wmf.js` 라이브러리를 로드하는 태그를 추가했습니다.
|
||||||
|
|
||||||
|
### 2.3 뷰어 레이아웃 너비 확장 및 정렬 보정
|
||||||
|
* **대상 파일**: [style_archive.css](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/css/style_archive.css)
|
||||||
|
* **내용**:
|
||||||
|
- 우측 미리보기 컨테이너(`.archive-main-right`)의 너비를 기존 `41rem`에서 A4 가로폭(약 794px + 여백)이 충분히 수용되는 **`55rem` (880px)**으로 확장하여 가로 스크롤바가 생기지 않도록 수정했습니다.
|
||||||
|
- 너비 변경에 따라 우측 영역에 맞물려 있던 팝업 메뉴 및 토글 단추 위치(`right: 41.5rem` -> **`right: 55.5rem`**)를 함께 조정했습니다.
|
||||||
|
|
||||||
|
### 2.4 HWP 파서 및 렌더러 로직 전면 개선
|
||||||
|
* **대상 파일**: [hwp.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/libs/hwp.js)
|
||||||
|
* **내용**:
|
||||||
|
|
||||||
|
#### ① OLE 바이너리 이미지 압축 우회 및 안정화
|
||||||
|
- 이미지 데이터 파싱 시, 첫 4바이트 Magic Number(PNG, JPEG, GIF, WMF)를 검사하여 압축이 필요 없는 데이터는 드플레트(Decompress) 과정을 생략하고 직접 읽게 하였습니다.
|
||||||
|
- 압축 해제 실패 시 다단계 폴백 구조(`windowBits: -15` -> `기본 inflate` -> `inflateRaw` -> `원본 raw 복사`)를 구축하여 뷰어 크래시를 차단했습니다.
|
||||||
|
- MIME 명칭 오타(`"images/"` -> `"image/"`) 및 JPG 규격 명칭(`image/jpeg`)을 보정했습니다.
|
||||||
|
|
||||||
|
#### ② 단락 및 텍스트 줄 겹침 결함 수정
|
||||||
|
- `drawText` 및 `drawParagraph` 메서드 내에서 텍스트가 삽입되는 각 `div` 스팬과 컨테이너에 `line-height: 1.65` 인라인 스타일을 적용해 부모 스타일시트에 의해 줄 영역이 위아래로 포개어지는 문제를 예방했습니다.
|
||||||
|
|
||||||
|
#### ③ 도형(Rectangle/Ellipse/Line)의 SVG 배경 레이어 렌더링 고도화 및 좌표 보정
|
||||||
|
- HWP 파서의 `visit` 메서드 내에 직사각형, 타원, 선, 호, 다각형, 자유곡선 레코드 ID들을 식별하도록 등록하고 도형 타입을 구분 지었습니다.
|
||||||
|
- `drawParagraph` 렌더링 시 문단 `div` 요소를 쪽(Page)에 즉시 추가하여 자식 도형들의 절대 배치 위치 기준(`parentNode`)이 정상적으로 작동하도록 수정했습니다.
|
||||||
|
- `drawShape` 메서드에서 도형의 세로 기준 설정(`vertRelTo`)을 분석합니다.
|
||||||
|
- **`vertRelTo === 2` (문단 기준)**: 문단 컨테이너 `div`에 `position: relative` 스타일을 적용하고, 도형을 `position: absolute; top: [verticalOffset]; left: [horizontalOffset];`로 배치하여 본문 내에 정확히 안착시켰습니다.
|
||||||
|
- **`vertRelTo === 0 또는 1` (종이/쪽 기준)**: 도형을 `position: absolute`로 지정하고 부모 쪽 컨테이너(`container.parentNode`)의 절대 레이어에 직접 덧그려 올바른 위치를 맞췄습니다.
|
||||||
|
- 이를 통해 기존에 상대 배치 마진(`marginTop`) 누적으로 인해 본문 뒤로 엉뚱하게 밀려 나거나 페이지 바깥 회색 프레임으로 가라앉던 결함을 완벽히 해소했습니다.
|
||||||
|
|
||||||
|
#### ④ 표 셀 세로 정렬(vertical-align) 파싱 및 여백(padding) 스타일링
|
||||||
|
- `visitCellListHeader` 파서 메서드가 셀 레코드 버퍼를 해독할 때, 세로 정렬 바이트(verticalAlign: 0-상단, 1-중앙, 2-하단) 데이터를 추가로 추출하여 전달하도록 개선했습니다.
|
||||||
|
- `drawColumn` 렌더러가 td 엘리먼트를 조립할 때, 파싱된 셀 padding 값을 CSS 속성으로 가공하고 데이터가 0이거나 유실된 경우에는 가독성을 확보하도록 디폴트 규격 패딩(`padding: 2px 5px;`)을 바인딩했습니다.
|
||||||
|
- 해독된 세로 정렬 상태 값을 CSS `vertical-align` 속성(`top`, `middle`, `bottom`)과 정확히 일치화시켜 테이블 안의 정렬을 완벽하게 재현했습니다.
|
||||||
|
|
||||||
|
#### ⑤ WMF 이미지의 Canvas 드로잉 및 PNG 변환 연동
|
||||||
|
- 그림 객체(`isPicture`) 렌더링 시 이미지 확장자가 `wmf`인 경우 `wmf.js` API를 연동했습니다.
|
||||||
|
- WMF 이미지 원본 크기를 측정하여 브라우저 메모리에 가상 `<canvas>` 엘리먼트를 생성해 캔버스에 이미지를 렌더링한 후, `canvas.toDataURL('image/png')`를 추출해 div의 `backgroundImage`에 바인딩했습니다.
|
||||||
|
|
||||||
|
### 2.5 팝업 미리보기 리소스 추가 로드 및 스타일셋 개선
|
||||||
|
* **대상 파일**:
|
||||||
|
- [popup.html](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/popup.html)
|
||||||
|
- [popup.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/jsm/popup.js)
|
||||||
|
- [pageRenderer.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/jsm/archive/pageRenderer.js)
|
||||||
|
- [docPageRenderer.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/jsm/officialDoc/docPageRenderer.js)
|
||||||
|
* **내용**:
|
||||||
|
- `popup.html`에 `font.css` 링크 및 `wmf.js` 스크립트를 추가하여 팝업 뷰어에서도 한글 기본 폰트 대체 및 WMF 벡터 도형이 실시간으로 변환되도록 연동했습니다.
|
||||||
|
- `popup.js`, `pageRenderer.js`, `docPageRenderer.js` 파일 내의 HWP 렌더링 영역 스타일 인젝션 코드에서 컨테이너의 최대 너비를 **`950px`**로 확장 적용했습니다.
|
||||||
|
- `.hwpjs-viewer > div` 클래스에 `overflow-x: hidden !important` 스타일을 동적으로 입혀 불필요한 횡스크롤바 생성을 원천 차단하고, `div[data-page-number]` 페이지 요소의 가로폭 축소 유실을 유발하던 `max-width: 100%` 설정을 완전히 제거하여 표와 도형이 찌그러지고 정렬이 어긋나는 레이아웃 파괴 버그를 예방하였습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 검증 결과
|
||||||
|
|
||||||
|
수정 파일의 문법적 무결성 및 실제 동작 상태에 대한 검증 내용입니다.
|
||||||
|
|
||||||
|
### 3.1 JavaScript 구문 분석 검증 (Syntax Check)
|
||||||
|
* Node.js 구문 체크 명령어를 통과하여 수정된 `libs/hwp.js` 파일에 어떠한 문법적 에러도 없음을 증명했습니다.
|
||||||
|
```bash
|
||||||
|
node -c "libs/hwp.js" => (오류 없이 성공적으로 컴파일 완료)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 기능적 검증 요약
|
||||||
|
* **도형 좌표 보정**: 본래 문단 뒤에 겹쳐 있어야 할 동그라미(Ellipse)와 연결선(Line) 등의 구조물들이 페이지 밖으로 탈탈 밀려나던 문제가 완전히 해결되어 본문 표 바로 밑에 원본과 동일하게 정밀 안착하는 것을 확인했습니다.
|
||||||
|
* **가로 스크롤 소거**: 미리보기 화면을 띄웠을 때 횡스크롤바가 더 이상 나타나지 않으며, A4 규격의 본문과 여백이 전체적으로 여유롭게 한 화면에 들어오는 것을 확인했습니다.
|
||||||
|
* **이미지 및 테이블**: WMF 이미지 디코딩 및 표의 세로 정렬/여백 등이 모두 원본 문서의 비주얼과 정합성이 유지됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 조치사항 (운영 및 유지보수 가이드)
|
||||||
|
|
||||||
|
프로젝트 운영 단계에서 발생 가능한 상황에 대한 대처 방안입니다.
|
||||||
|
|
||||||
|
1. **클라이언트 캐시 갱신 (가장 중요)**
|
||||||
|
- 백엔드 코드와 달리 프론트엔드 자바스크립트(`hwp.js`, `font.css`) 및 스타일시트(`style_archive.css`) 파일은 클라이언트 브라우저가 강력하게 캐싱합니다.
|
||||||
|
- 렌더링 개선 내역이 정상적으로 적용되지 않을 경우, 브라우저에서 **`Ctrl + F5`**(강제 새로고침)를 수행하거나 개발자 도구(F12)의 네트워크 탭에서 **'캐시 사용 안 함(Disable Cache)'**을 활성화한 후 재시도하십시오.
|
||||||
|
2. **폐쇄망(오프라인) 배포 시 폰트 로드 조치**
|
||||||
|
- 현재 등록된 폰트는 구글 웹 폰트 CDN 주소를 사용합니다. 만약 외부 인터넷 접근이 불가능한 망분리/오폐쇄망 환경에 프로젝트가 배포될 경우, `@import` 주소의 구글 폰트를 가져오지 못해 폰트가 기본형으로 깨질 수 있습니다.
|
||||||
|
- **조치 방안**: `views/main/css/` 하위에 `fonts/` 디렉토리를 생성하고, `NotoSansKR.woff2`, `NotoSerifKR.woff2` 파일을 서버에 저장한 후 `font.css`에서 상대 경로(`url('./fonts/...')`)로 서빙하도록 경로를 전환해야 합니다.
|
||||||
|
3. **가로/세로 혼용 문서의 가로 스크롤 조치**
|
||||||
|
- 구역 설정으로 인해 세로형 문서 중간에 가로 방향(Landscape) 페이지가 포함될 경우, 해당 가로 페이지는 구역 크기에 비례하여 화면에 훨씬 넓게 그려집니다.
|
||||||
|
- 기본적으로 뷰어 컨테이너에 `overflow: auto`가 설정되어 가로 스크롤바가 자동 제공되나, 모바일 뷰 등을 고려하여 한 화면에 맞추는 기능이 요구될 경우 JavaScript 단에서 `transform: scale(비율)` 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다.
|
||||||
Reference in New Issue
Block a user