Compare commits

...

10 Commits

Author SHA1 Message Date
Hyungi Ahn
ec7d13ced7 Fix: Alpine.js 드롭다운 반응성 문제 해결
- 데이터 로드 후 Alpine.js DOM 업데이트가 제대로 되지 않는 문제 수정
- $nextTick과 setTimeout을 사용하여 강제 반응성 트리거
- matched_pdf_id 값을 일시적으로 변경했다가 복원하여 UI 업데이트 강제 실행
2025-09-03 18:38:50 +09:00
Hyungi Ahn
e983d01a83 Fix: PDF 매칭 드롭다운 값 바인딩 문제 해결
- Alpine.js에서 :value="null"이 문자열로 변환되는 문제 수정
- option value를 빈 문자열("")로 변경
- JavaScript에서 null 값을 빈 문자열로 변환하여 UI 바인딩 개선
- 저장 시 빈 문자열과 null 모두 처리하도록 수정
2025-09-03 18:35:50 +09:00
Hyungi Ahn
77c18d31a9 Fix: 서적 편집 페이지 PDF 매칭 UI 개선
- PDF 매칭 드롭다운에서 현재 선택된 PDF가 올바르게 표시되도록 수정
- null 값과 빈 문자열 처리 개선 (option value를 null로 변경)
- PDF 매칭된 문서는 녹색 배경과 상태 표시 점으로 시각적 구분
- JavaScript에서 빈 문자열을 null로 변환하여 일관성 유지
2025-09-03 18:32:58 +09:00
Hyungi Ahn
ca49ffec40 Fix: PDF 다운로드 시 캐시 문제 해결
- 문서 뷰어에서 PDF 다운로드 시 최신 문서 정보를 재로드
- 캐시된 matched_pdf_id가 null인 경우 데이터베이스에서 최신 정보 가져오기
- PDF 매칭 정보 업데이트 로그 추가
2025-09-03 18:31:35 +09:00
Hyungi Ahn
185db89585 Fix: 서적 편집 저장 시 문서 순서 업데이트 추가
- saveChanges() 함수에서 저장 전에 updateDisplayOrder() 호출
- 문서 순서가 올바르게 저장되도록 수정
2025-09-03 18:19:51 +09:00
Hyungi Ahn
8f12eb4f76 Fix: PDF 매칭 저장 기능 수정
- 백엔드 문서 업데이트 API에서 DocumentResponse 생성자 수정
- original_filename 필드 추가로 PDF 매칭 정보 제대로 반환
- uploader 필드 null 체크 추가
- 프론트엔드 저장 과정에 상세 디버깅 로그 추가
- PDF 매칭 정보와 순서 저장 과정 로그 추가
2025-09-03 18:10:42 +09:00
Hyungi Ahn
b5602cbf44 Debug: 서적 수정 버튼 디버깅 로그 추가
- openBookEditor() 함수에 상세 디버깅 로그 추가
- URL 파라미터 파싱 과정 로그 추가
- bookId 값과 타입 확인 로그 추가
- 버튼 클릭 이벤트 확인을 위한 로그 추가
2025-09-03 18:03:03 +09:00
Hyungi Ahn
0833b99e6f Fix: 메뉴 구조 개선 및 관리자 권한 수정
- 프로필 관리 vs 계정 관리 구분 명확화:
  - 프로필 관리: 개인 계정 설정 (모든 사용자)
  - 계정 관리: 전체 사용자 관리 (관리자만)
- 관리자 메뉴 디버깅 로그 추가
- admin@test.com 계정을 관리자로 업데이트
- 아이콘 변경: users → users-cog (계정 관리)
2025-09-03 17:59:10 +09:00
Hyungi Ahn
43e2614253 Feature: 관리자 메뉴 개선 및 새 관리 페이지 추가
- 프로필 드롭다운에서 중복 메뉴 제거 (프로필 관리 + 계정 설정 → 계정 설정)
- 관리자 전용 메뉴 추가:
  - 백업/복원 관리 페이지 (backup-restore.html)
  - 시스템 로그 페이지 (logs.html)
- 관리자 메뉴에 색상 구분 아이콘 적용
- account-settings.html 삭제 (profile.html로 통합)
2025-09-03 17:46:41 +09:00
Hyungi Ahn
62675e9bd5 UI: 프로필 드롭다운 메뉴 심플화
- 복잡한 그라디언트와 설명 텍스트 제거
- 더 작고 깔끔한 드롭다운 메뉴로 변경
- 아이콘과 텍스트만으로 심플하게 구성
- 불필요한 CSS 스타일 제거
2025-09-03 17:42:52 +09:00
10 changed files with 810 additions and 457 deletions

View File

@@ -802,13 +802,14 @@ async def update_document(
created_at=document.created_at,
updated_at=document.updated_at,
document_date=document.document_date,
uploader_name=document.uploader.full_name or document.uploader.email,
uploader_name=document.uploader.full_name or document.uploader.email if document.uploader else "Unknown",
tags=[tag.name for tag in document.tags],
book_id=str(document.book.id) if document.book else None,
book_title=document.book.title if document.book else None,
book_author=document.book.author if document.book else None,
sort_order=document.sort_order,
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None,
original_filename=document.original_filename
)

View File

@@ -1,363 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>계정 설정 - Document Server</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 메인 앱 -->
<div x-data="accountSettingsApp()" x-init="init()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8 max-w-4xl">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center space-x-3 mb-2">
<i class="fas fa-cog text-2xl text-blue-600"></i>
<h1 class="text-3xl font-bold text-gray-900">계정 설정</h1>
</div>
<p class="text-gray-600">계정 정보와 환경 설정을 관리하세요</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : 'bg-red-100 text-red-800 border border-red-200'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-600' : 'fas fa-exclamation-circle text-red-600'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<!-- 설정 섹션들 -->
<div class="space-y-8">
<!-- 프로필 정보 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center space-x-3 mb-6">
<i class="fas fa-user text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">프로필 정보</h2>
</div>
<form @submit.prevent="updateProfile()" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input type="email" x-model="profile.email" disabled
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
<p class="mt-1 text-xs text-gray-500">이메일은 변경할 수 없습니다</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">이름</label>
<input type="text" x-model="profile.full_name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="이름을 입력하세요">
</div>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<i class="fas fa-save"></i>
<span x-text="loading ? '저장 중...' : '프로필 저장'"></span>
</button>
</div>
</form>
</div>
<!-- 비밀번호 변경 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center space-x-3 mb-6">
<i class="fas fa-lock text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">비밀번호 변경</h2>
</div>
<form @submit.prevent="changePassword()" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">현재 비밀번호</label>
<input type="password" x-model="passwordForm.currentPassword" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="현재 비밀번호를 입력하세요">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호</label>
<input type="password" x-model="passwordForm.newPassword" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="새 비밀번호를 입력하세요">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호 확인</label>
<input type="password" x-model="passwordForm.confirmPassword" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="새 비밀번호를 다시 입력하세요">
</div>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="loading"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<i class="fas fa-key"></i>
<span x-text="loading ? '변경 중...' : '비밀번호 변경'"></span>
</button>
</div>
</form>
</div>
<!-- 환경 설정 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center space-x-3 mb-6">
<i class="fas fa-palette text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">환경 설정</h2>
</div>
<form @submit.prevent="updateSettings()" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">테마</label>
<select x-model="settings.theme"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="light">라이트</option>
<option value="dark">다크</option>
<option value="auto">자동</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">언어</label>
<select x-model="settings.language"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">시간대</label>
<select x-model="settings.timezone"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="Asia/Seoul">서울 (UTC+9)</option>
<option value="UTC">UTC (UTC+0)</option>
<option value="America/New_York">뉴욕 (UTC-5)</option>
<option value="Europe/London">런던 (UTC+0)</option>
</select>
</div>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="loading"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<i class="fas fa-cog"></i>
<span x-text="loading ? '저장 중...' : '설정 저장'"></span>
</button>
</div>
</form>
</div>
<!-- 계정 정보 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center space-x-3 mb-6">
<i class="fas fa-info-circle text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">계정 정보</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-700">계정 ID:</span>
<span class="text-gray-600 ml-2" x-text="profile.id"></span>
</div>
<div>
<span class="font-medium text-gray-700">역할:</span>
<span class="text-gray-600 ml-2" x-text="getRoleText(profile.role)"></span>
</div>
<div>
<span class="font-medium text-gray-700">계정 생성일:</span>
<span class="text-gray-600 ml-2" x-text="formatDate(profile.created_at)"></span>
</div>
<div>
<span class="font-medium text-gray-700">마지막 로그인:</span>
<span class="text-gray-600 ml-2" x-text="formatDate(profile.last_login)"></span>
</div>
<div>
<span class="font-medium text-gray-700">세션 타임아웃:</span>
<span class="text-gray-600 ml-2" x-text="profile.session_timeout_minutes === 0 ? '무제한' : profile.session_timeout_minutes + '분'"></span>
</div>
<div>
<span class="font-medium text-gray-700">계정 상태:</span>
<span class="ml-2" :class="profile.is_active ? 'text-green-600' : 'text-red-600'" x-text="profile.is_active ? '활성' : '비활성'"></span>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- API 스크립트 -->
<script src="static/js/api.js"></script>
<!-- 계정 설정 스크립트 -->
<script>
function accountSettingsApp() {
return {
loading: false,
profile: {
id: '',
email: '',
full_name: '',
role: '',
is_active: false,
created_at: '',
last_login: '',
session_timeout_minutes: 5
},
passwordForm: {
currentPassword: '',
newPassword: '',
confirmPassword: ''
},
settings: {
theme: 'light',
language: 'ko',
timezone: 'Asia/Seoul'
},
notification: {
show: false,
message: '',
type: 'success'
},
async init() {
console.log('🔧 계정 설정 앱 초기화');
await this.loadProfile();
},
async loadProfile() {
try {
const user = await api.getCurrentUser();
this.profile = { ...user };
this.settings = {
theme: user.theme || 'light',
language: user.language || 'ko',
timezone: user.timezone || 'Asia/Seoul'
};
console.log('✅ 프로필 로드 완료:', user);
} catch (error) {
console.error('❌ 프로필 로드 실패:', error);
this.showNotification('프로필을 불러올 수 없습니다.', 'error');
}
},
async updateProfile() {
this.loading = true;
try {
const response = await api.put(`/users/${this.profile.id}`, {
full_name: this.profile.full_name
});
this.showNotification('프로필이 성공적으로 업데이트되었습니다.', 'success');
await this.loadProfile(); // 프로필 다시 로드
} catch (error) {
console.error('❌ 프로필 업데이트 실패:', error);
this.showNotification('프로필 업데이트에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
async changePassword() {
if (this.passwordForm.newPassword !== this.passwordForm.confirmPassword) {
this.showNotification('새 비밀번호가 일치하지 않습니다.', 'error');
return;
}
this.loading = true;
try {
await api.post('/auth/change-password', {
current_password: this.passwordForm.currentPassword,
new_password: this.passwordForm.newPassword
});
this.showNotification('비밀번호가 성공적으로 변경되었습니다.', 'success');
this.passwordForm = {
currentPassword: '',
newPassword: '',
confirmPassword: ''
};
} catch (error) {
console.error('❌ 비밀번호 변경 실패:', error);
this.showNotification('비밀번호 변경에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
async updateSettings() {
this.loading = true;
try {
await api.put(`/users/${this.profile.id}`, {
theme: this.settings.theme,
language: this.settings.language,
timezone: this.settings.timezone
});
this.showNotification('환경 설정이 성공적으로 저장되었습니다.', 'success');
await this.loadProfile(); // 프로필 다시 로드
} catch (error) {
console.error('❌ 환경 설정 저장 실패:', error);
this.showNotification('환경 설정 저장에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
message: message,
type: type
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
},
getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
},
formatDate(dateString) {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('ko-KR');
}
};
}
</script>
</body>
</html>

View File

@@ -0,0 +1,317 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>백업/복원 관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="static/js/auth-guard.js"></script>
</head>
<body class="bg-gray-50">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8" x-data="backupRestoreApp()">
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">백업/복원 관리</h1>
<p class="text-gray-600">시스템 데이터를 백업하고 복원할 수 있습니다.</p>
</div>
<!-- 백업 섹션 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-download text-blue-500 mr-3"></i>
데이터 백업
</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 전체 백업 -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-semibold text-gray-900 mb-2">전체 백업</h3>
<p class="text-sm text-gray-600 mb-4">데이터베이스와 업로드된 파일을 모두 백업합니다.</p>
<button @click="createBackup('full')"
:disabled="loading"
class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-database mr-2"></i>
<span x-show="!loading">전체 백업 생성</span>
<span x-show="loading">백업 중...</span>
</button>
</div>
<!-- 데이터베이스만 백업 -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-semibold text-gray-900 mb-2">데이터베이스 백업</h3>
<p class="text-sm text-gray-600 mb-4">데이터베이스만 백업합니다 (파일 제외).</p>
<button @click="createBackup('db')"
:disabled="loading"
class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-table mr-2"></i>
<span x-show="!loading">DB 백업 생성</span>
<span x-show="loading">백업 중...</span>
</button>
</div>
</div>
<!-- 백업 상태 -->
<div x-show="backupStatus" class="mt-4 p-4 rounded-lg" :class="backupStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
<div class="flex items-center">
<i :class="backupStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="backupStatus.message"></span>
</div>
</div>
</div>
</div>
<!-- 백업 목록 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-history text-green-500 mr-3"></i>
백업 목록
</h2>
</div>
<div class="p-6">
<div x-show="backups.length === 0" class="text-center py-8 text-gray-500">
<i class="fas fa-inbox text-4xl mb-4"></i>
<p>백업 파일이 없습니다.</p>
</div>
<div x-show="backups.length > 0" class="space-y-4">
<template x-for="backup in backups" :key="backup.id">
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div class="flex items-center space-x-4">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-archive text-blue-600"></i>
</div>
<div>
<h4 class="font-medium text-gray-900" x-text="backup.name"></h4>
<p class="text-sm text-gray-500">
<span x-text="backup.type === 'full' ? '전체 백업' : 'DB 백업'"></span>
<span x-text="backup.size"></span>
<span x-text="backup.date"></span>
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="downloadBackup(backup)"
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fas fa-download mr-1"></i>
다운로드
</button>
<button @click="deleteBackup(backup)"
class="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700">
<i class="fas fa-trash mr-1"></i>
삭제
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 복원 섹션 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-upload text-orange-500 mr-3"></i>
데이터 복원
</h2>
</div>
<div class="p-6">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-600 mr-2"></i>
<span class="text-yellow-800 font-medium">주의사항</span>
</div>
<p class="text-yellow-700 text-sm mt-2">
복원 작업은 현재 데이터를 완전히 덮어씁니다. 복원 전에 반드시 현재 데이터를 백업하세요.
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">백업 파일 선택</label>
<input type="file"
@change="handleFileSelect"
accept=".sql,.tar.gz,.zip"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<div x-show="selectedFile">
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-medium text-gray-900 mb-2">선택된 파일</h4>
<p class="text-sm text-gray-600" x-text="selectedFile?.name"></p>
<p class="text-sm text-gray-500" x-text="selectedFile ? formatFileSize(selectedFile.size) : ''"></p>
</div>
</div>
<button @click="restoreBackup()"
:disabled="!selectedFile || loading"
class="w-full bg-orange-600 text-white px-4 py-3 rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium">
<i class="fas fa-upload mr-2"></i>
<span x-show="!loading">복원 실행</span>
<span x-show="loading">복원 중...</span>
</button>
</div>
<!-- 복원 상태 -->
<div x-show="restoreStatus" class="mt-4 p-4 rounded-lg" :class="restoreStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
<div class="flex items-center">
<i :class="restoreStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="restoreStatus.message"></span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<script>
function backupRestoreApp() {
return {
loading: false,
backups: [],
selectedFile: null,
backupStatus: null,
restoreStatus: null,
init() {
this.loadBackups();
},
async loadBackups() {
try {
// 실제 구현에서는 백엔드 API 호출
// this.backups = await window.api.getBackups();
// 임시 데모 데이터
this.backups = [
{
id: '1',
name: 'backup_2025_09_03_full.tar.gz',
type: 'full',
size: '125.4 MB',
date: '2025년 9월 3일 15:30'
},
{
id: '2',
name: 'backup_2025_09_02_db.sql',
type: 'db',
size: '2.1 MB',
date: '2025년 9월 2일 10:15'
}
];
} catch (error) {
console.error('백업 목록 로드 실패:', error);
}
},
async createBackup(type) {
this.loading = true;
this.backupStatus = null;
try {
// 실제 구현에서는 백엔드 API 호출
// const result = await window.api.createBackup(type);
// 임시 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 3000));
this.backupStatus = {
type: 'success',
message: `${type === 'full' ? '전체' : 'DB'} 백업이 성공적으로 생성되었습니다.`
};
this.loadBackups();
} catch (error) {
this.backupStatus = {
type: 'error',
message: '백업 생성 중 오류가 발생했습니다: ' + error.message
};
} finally {
this.loading = false;
}
},
downloadBackup(backup) {
// 실제 구현에서는 백엔드에서 파일 다운로드
alert(`${backup.name} 다운로드를 시작합니다.`);
},
async deleteBackup(backup) {
if (!confirm(`${backup.name}을(를) 삭제하시겠습니까?`)) {
return;
}
try {
// 실제 구현에서는 백엔드 API 호출
// await window.api.deleteBackup(backup.id);
this.backups = this.backups.filter(b => b.id !== backup.id);
alert('백업이 삭제되었습니다.');
} catch (error) {
alert('백업 삭제 중 오류가 발생했습니다: ' + error.message);
}
},
handleFileSelect(event) {
this.selectedFile = event.target.files[0];
this.restoreStatus = null;
},
async restoreBackup() {
if (!this.selectedFile) return;
if (!confirm('현재 데이터가 모두 삭제되고 백업 데이터로 복원됩니다. 계속하시겠습니까?')) {
return;
}
this.loading = true;
this.restoreStatus = null;
try {
// 실제 구현에서는 백엔드 API 호출
// const formData = new FormData();
// formData.append('backup_file', this.selectedFile);
// await window.api.restoreBackup(formData);
// 임시 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 5000));
this.restoreStatus = {
type: 'success',
message: '백업이 성공적으로 복원되었습니다. 페이지를 새로고침해주세요.'
};
} catch (error) {
this.restoreStatus = {
type: 'error',
message: '복원 중 오류가 발생했습니다: ' + error.message
};
} finally {
this.loading = false;
}
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
}
</script>
</body>
</html>

View File

@@ -146,14 +146,19 @@
<!-- PDF 매칭 및 컨트롤 -->
<div class="flex items-center space-x-3">
<!-- PDF 매칭 드롭다운 -->
<div class="min-w-48">
<div class="min-w-48 relative">
<select x-model="doc.matched_pdf_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
:class="doc.matched_pdf_id ? 'border-green-300 bg-green-50' : 'border-gray-300'"
class="w-full px-3 py-2 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<option value="">PDF 매칭 없음</option>
<template x-for="pdf in availablePDFs" :key="pdf.id">
<option :value="pdf.id" x-text="pdf.title"></option>
</template>
</select>
<!-- 매칭 상태 표시 -->
<div x-show="doc.matched_pdf_id" class="absolute -top-1 -right-1">
<div class="w-3 h-3 bg-green-500 rounded-full border-2 border-white"></div>
</div>
</div>
<!-- 이동 버튼 -->

View File

@@ -148,80 +148,68 @@
<div class="flex items-center space-x-3" id="user-menu">
<!-- 로그인된 사용자 드롭다운 -->
<div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
<button @click="open = !open" class="user-menu-btn">
<div class="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
<button @click="open = !open" class="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<i class="fas fa-user text-white text-sm"></i>
</div>
<div class="hidden sm:block text-left">
<div class="text-sm font-semibold text-gray-900" id="user-name">User</div>
<div class="text-xs text-gray-500" id="user-role">사용자</div>
<div class="hidden sm:block">
<div class="text-sm font-medium text-gray-900" id="user-name">User</div>
</div>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<!-- 드롭다운 메뉴 -->
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="user-dropdown">
<!-- 사용자 정보 -->
<div class="px-4 py-4 border-b border-gray-100 bg-gradient-to-r from-blue-50 to-indigo-50">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-md">
<i class="fas fa-user text-white"></i>
</div>
<div>
<div class="font-semibold text-gray-900" id="dropdown-user-name">User</div>
<div class="text-sm text-gray-600" id="dropdown-user-email">user@example.com</div>
<div class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 mt-1" id="dropdown-user-role">사용자</div>
</div>
</div>
<!-- 심플한 드롭다운 메뉴 -->
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<!-- 사용자 정보 헤더 -->
<div class="px-4 py-3 border-b border-gray-100">
<div class="text-sm font-medium text-gray-900" id="dropdown-user-name">User</div>
<div class="text-sm text-gray-500" id="dropdown-user-email">user@example.com</div>
</div>
<!-- 메뉴 항목들 -->
<div class="py-2">
<a href="profile.html" class="user-menu-item">
<i class="fas fa-user-edit text-blue-500"></i>
<div>
<div class="font-medium">프로필 관리</div>
<div class="text-xs text-gray-500">개인 정보 수정</div>
</div>
</a>
<a href="account-settings.html" class="user-menu-item">
<i class="fas fa-cog text-gray-500"></i>
<div>
<div class="font-medium">계정 설정</div>
<div class="text-xs text-gray-500">환경 설정 및 보안</div>
</div>
<div class="py-1">
<a href="profile.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-user-edit w-4 h-4 mr-3 text-gray-400"></i>
프로필 관리
</a>
<!-- 관리자 메뉴 (관리자만 표시) -->
<div class="hidden" id="admin-menu-section">
<div class="border-t border-gray-100 my-2"></div>
<div class="border-t border-gray-100 my-1"></div>
<div class="px-4 py-2">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider">관리자 메뉴</div>
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">관리자</div>
</div>
<a href="user-management.html" class="user-menu-item">
<i class="fas fa-users text-indigo-500"></i>
<div>
<div class="font-medium">사용자 관리</div>
<div class="text-xs text-gray-500">계정 및 권한 관리</div>
</div>
<a href="user-management.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-users-cog w-4 h-4 mr-3 text-indigo-500"></i>
계정 관리
</a>
<a href="system-settings.html" class="user-menu-item">
<i class="fas fa-server text-green-500"></i>
<div>
<div class="font-medium">시스템 설정</div>
<div class="text-xs text-gray-500">시스템 전체 설정</div>
</div>
<a href="system-settings.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-server w-4 h-4 mr-3 text-green-500"></i>
시스템 설정
</a>
<a href="backup-restore.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-database w-4 h-4 mr-3 text-blue-500"></i>
백업/복원
</a>
<a href="logs.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-file-alt w-4 h-4 mr-3 text-orange-500"></i>
시스템 로그
</a>
</div>
<!-- 로그아웃 -->
<div class="border-t border-gray-100 my-2"></div>
<button onclick="handleLogout()" class="user-menu-item text-red-600 hover:bg-red-50 w-full">
<i class="fas fa-sign-out-alt text-red-500"></i>
<div>
<div class="font-medium">로그아웃</div>
<div class="text-xs text-gray-500">계정에서 로그아웃</div>
</div>
<div class="border-t border-gray-100 my-1"></div>
<button onclick="handleLogout()" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
<i class="fas fa-sign-out-alt w-4 h-4 mr-3"></i>
로그아웃
</button>
</div>
</div>
@@ -310,36 +298,7 @@
}
/* 사용자 메뉴 스타일 */
.user-menu-btn {
@apply flex items-center space-x-3 px-3 py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
border: 1px solid transparent;
}
.user-menu-btn:hover {
@apply shadow-sm;
border-color: rgba(0, 0, 0, 0.05);
}
.user-dropdown {
@apply absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl py-0 z-50;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.user-menu-item {
@apply flex items-center space-x-3 px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-all duration-150 cursor-pointer;
}
.user-menu-item:hover {
@apply bg-gradient-to-r from-gray-50 to-blue-50 text-gray-900;
transform: translateX(2px);
}
.user-menu-item i {
@apply w-5 h-5 flex items-center justify-center;
}
/* 사용자 메뉴 관련 스타일은 인라인으로 처리됨 */
/* 로그인 버튼 모던 스타일 */
.login-btn-modern {

331
frontend/logs.html Normal file
View File

@@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>시스템 로그 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="static/js/auth-guard.js"></script>
</head>
<body class="bg-gray-50">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8" x-data="logsApp()">
<div class="max-w-6xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">시스템 로그</h1>
<p class="text-gray-600">시스템 활동과 오류 로그를 확인할 수 있습니다.</p>
</div>
<!-- 필터 및 컨트롤 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-6">
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- 로그 레벨 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">로그 레벨</label>
<select x-model="filters.level" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="">전체</option>
<option value="INFO">정보</option>
<option value="WARNING">경고</option>
<option value="ERROR">오류</option>
<option value="DEBUG">디버그</option>
</select>
</div>
<!-- 날짜 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">날짜</label>
<input type="date" x-model="filters.date" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
</div>
<!-- 검색 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
<input type="text" x-model="filters.search" @input="filterLogs()" placeholder="메시지 검색..." class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
</div>
<!-- 컨트롤 버튼 -->
<div class="flex items-end space-x-2">
<button @click="refreshLogs()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
<i class="fas fa-sync-alt mr-1"></i>
새로고침
</button>
<button @click="clearLogs()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">
<i class="fas fa-trash mr-1"></i>
삭제
</button>
</div>
</div>
</div>
</div>
<!-- 로그 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-info-circle text-blue-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">정보</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.info"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-yellow-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">경고</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.warning"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">오류</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.error"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<i class="fas fa-bug text-gray-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">디버그</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.debug"></p>
</div>
</div>
</div>
</div>
<!-- 로그 목록 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-list text-gray-500 mr-3"></i>
로그 목록
<span class="ml-2 text-sm font-normal text-gray-500">(<span x-text="filteredLogs.length"></span>개)</span>
</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">시간</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">레벨</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">소스</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">메시지</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-for="log in paginatedLogs" :key="log.id">
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900" x-text="log.timestamp"></td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-blue-100 text-blue-800': log.level === 'INFO',
'bg-yellow-100 text-yellow-800': log.level === 'WARNING',
'bg-red-100 text-red-800': log.level === 'ERROR',
'bg-gray-100 text-gray-800': log.level === 'DEBUG'
}"
x-text="log.level">
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" x-text="log.source"></td>
<td class="px-6 py-4 text-sm text-gray-900">
<div class="max-w-md truncate" x-text="log.message" :title="log.message"></div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500">
<span x-text="(currentPage - 1) * pageSize + 1"></span>-<span x-text="Math.min(currentPage * pageSize, filteredLogs.length)"></span> / <span x-text="filteredLogs.length"></span>
</div>
<div class="flex space-x-2">
<button @click="currentPage > 1 && currentPage--"
:disabled="currentPage <= 1"
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
이전
</button>
<button @click="currentPage < totalPages && currentPage++"
:disabled="currentPage >= totalPages"
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
다음
</button>
</div>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<script>
function logsApp() {
return {
logs: [],
filteredLogs: [],
currentPage: 1,
pageSize: 50,
filters: {
level: '',
date: '',
search: ''
},
stats: {
info: 0,
warning: 0,
error: 0,
debug: 0
},
get totalPages() {
return Math.ceil(this.filteredLogs.length / this.pageSize);
},
get paginatedLogs() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.filteredLogs.slice(start, end);
},
init() {
this.loadLogs();
// 자동 새로고침 (30초마다)
setInterval(() => {
this.refreshLogs();
}, 30000);
},
async loadLogs() {
try {
// 실제 구현에서는 백엔드 API 호출
// this.logs = await window.api.getLogs();
// 임시 데모 데이터
this.logs = this.generateDemoLogs();
this.filterLogs();
this.updateStats();
} catch (error) {
console.error('로그 로드 실패:', error);
}
},
generateDemoLogs() {
const levels = ['INFO', 'WARNING', 'ERROR', 'DEBUG'];
const sources = ['auth', 'documents', 'database', 'nginx', 'system'];
const messages = [
'사용자 로그인 성공: admin@test.com',
'문서 업로드 완료: test.pdf',
'데이터베이스 연결 실패',
'API 요청 처리 완료',
'메모리 사용량 경고: 85%',
'백업 작업 시작',
'인증 토큰 만료',
'파일 업로드 오류',
'시스템 재시작 완료',
'캐시 정리 작업 완료'
];
const logs = [];
for (let i = 0; i < 200; i++) {
const date = new Date();
date.setMinutes(date.getMinutes() - i * 5);
logs.push({
id: i + 1,
timestamp: date.toLocaleString('ko-KR'),
level: levels[Math.floor(Math.random() * levels.length)],
source: sources[Math.floor(Math.random() * sources.length)],
message: messages[Math.floor(Math.random() * messages.length)]
});
}
return logs.reverse();
},
filterLogs() {
this.filteredLogs = this.logs.filter(log => {
let matches = true;
if (this.filters.level && log.level !== this.filters.level) {
matches = false;
}
if (this.filters.date) {
const logDate = new Date(log.timestamp).toISOString().split('T')[0];
if (logDate !== this.filters.date) {
matches = false;
}
}
if (this.filters.search && !log.message.toLowerCase().includes(this.filters.search.toLowerCase())) {
matches = false;
}
return matches;
});
this.currentPage = 1;
},
updateStats() {
this.stats = {
info: this.logs.filter(log => log.level === 'INFO').length,
warning: this.logs.filter(log => log.level === 'WARNING').length,
error: this.logs.filter(log => log.level === 'ERROR').length,
debug: this.logs.filter(log => log.level === 'DEBUG').length
};
},
async refreshLogs() {
await this.loadLogs();
},
async clearLogs() {
if (!confirm('모든 로그를 삭제하시겠습니까?')) {
return;
}
try {
// 실제 구현에서는 백엔드 API 호출
// await window.api.clearLogs();
this.logs = [];
this.filteredLogs = [];
this.updateStats();
alert('로그가 삭제되었습니다.');
} catch (error) {
alert('로그 삭제 중 오류가 발생했습니다: ' + error.message);
}
}
}
}
</script>
</body>
</html>

View File

@@ -38,6 +38,9 @@ window.bookDocumentsApp = () => ({
this.bookId = urlParams.get('book_id') || urlParams.get('bookId'); // 둘 다 지원
console.log('📖 서적 ID:', this.bookId);
console.log('🔍 전체 URL 파라미터:', window.location.search);
console.log('🔍 URLSearchParams 객체:', urlParams);
console.log('🔍 book_id 파라미터:', urlParams.get('book_id'));
console.log('🔍 bookId 파라미터:', urlParams.get('bookId'));
},
// 인증 상태 확인
@@ -166,11 +169,23 @@ window.bookDocumentsApp = () => ({
// 서적 편집 페이지 열기
openBookEditor() {
console.log('🔧 서적 편집 버튼 클릭됨');
console.log('📖 현재 bookId:', this.bookId);
console.log('🔍 bookId 타입:', typeof this.bookId);
if (this.bookId === 'none') {
alert('서적 미분류 문서들은 편집할 수 없습니다.');
return;
}
window.location.href = `book-editor.html?bookId=${this.bookId}`;
if (!this.bookId) {
alert('서적 ID가 없습니다. 페이지를 새로고침해주세요.');
return;
}
const targetUrl = `book-editor.html?bookId=${this.bookId}`;
console.log('🔗 이동할 URL:', targetUrl);
window.location.href = targetUrl;
},
// 문서 수정

View File

@@ -90,6 +90,25 @@ window.bookEditorApp = () => ({
console.log('📄 서적 문서들:', this.documents.length, '개');
// 각 문서의 PDF 매칭 상태 확인
this.documents.forEach((doc, index) => {
console.log(`📄 문서 ${index + 1}: ${doc.title}`);
console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`);
console.log(` - sort_order: ${doc.sort_order || 'null'}`);
// null 값을 빈 문자열로 변환 (UI 바인딩을 위해)
if (doc.matched_pdf_id === null) {
doc.matched_pdf_id = "";
}
// 디버깅: 실제 값과 타입 확인
console.log(` - matched_pdf_id 타입: ${typeof doc.matched_pdf_id}`);
console.log(` - matched_pdf_id 값: "${doc.matched_pdf_id}"`);
console.log(` - 빈 문자열인가? ${doc.matched_pdf_id === ""}`);
console.log(` - null인가? ${doc.matched_pdf_id === null}`);
console.log(` - undefined인가? ${doc.matched_pdf_id === undefined}`);
});
// 사용 가능한 PDF 문서들 로드 (현재 서적의 PDF만)
console.log('🔍 현재 서적 ID:', this.bookId);
console.log('🔍 전체 문서 수:', allDocuments.length);
@@ -112,11 +131,17 @@ window.bookEditorApp = () => ({
console.log('📎 현재 서적의 PDF:', this.availablePDFs.length, '개');
console.log('📎 현재 서적 PDF 목록:', this.availablePDFs.map(pdf => ({
id: pdf.id,
title: pdf.title,
book_id: pdf.book_id,
book_title: pdf.book_title
})));
// 각 PDF의 ID 확인
this.availablePDFs.forEach((pdf, index) => {
console.log(`📎 PDF ${index + 1}: ID="${pdf.id}", 제목="${pdf.title}"`);
});
// 디버깅: 다른 서적의 PDF들도 확인
const otherBookPDFs = allPDFs.filter(doc => doc.book_id !== this.bookId);
console.log('🔍 다른 서적의 PDF:', otherBookPDFs.length, '개');
@@ -128,6 +153,23 @@ window.bookEditorApp = () => ({
})));
}
// Alpine.js DOM 업데이트 강제 실행
this.$nextTick(() => {
console.log('🔄 Alpine.js DOM 업데이트 완료');
// DOM이 완전히 렌더링된 후 실행
setTimeout(() => {
this.documents.forEach((doc, index) => {
if (doc.matched_pdf_id) {
console.log(`🔧 문서 ${index + 1} 강제 업데이트: ${doc.matched_pdf_id}`);
// Alpine.js 반응성 트리거
const oldValue = doc.matched_pdf_id;
doc.matched_pdf_id = "";
doc.matched_pdf_id = oldValue;
}
});
}, 100);
});
} catch (error) {
console.error('서적 데이터 로드 실패:', error);
this.error = '데이터를 불러오는데 실패했습니다: ' + error.message;
@@ -206,24 +248,36 @@ window.bookEditorApp = () => ({
if (this.saving) return;
this.saving = true;
console.log('💾 저장 시작...');
try {
// 저장 전에 순서 업데이트
this.updateDisplayOrder();
// 서적 정보 업데이트
console.log('📚 서적 정보 업데이트 중...');
await window.api.updateBook(this.bookId, {
title: this.bookInfo.title,
author: this.bookInfo.author,
description: this.bookInfo.description
});
console.log('✅ 서적 정보 업데이트 완료');
// 각 문서의 순서와 PDF 매칭 정보 업데이트
const updatePromises = this.documents.map(doc => {
console.log('📄 문서 업데이트 시작...');
const updatePromises = this.documents.map((doc, index) => {
console.log(`📄 문서 ${index + 1}/${this.documents.length}: ${doc.title}`);
console.log(` - sort_order: ${doc.sort_order}`);
console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`);
return window.api.updateDocument(doc.id, {
sort_order: doc.sort_order,
matched_pdf_id: doc.matched_pdf_id || null
matched_pdf_id: doc.matched_pdf_id === "" || doc.matched_pdf_id === null ? null : doc.matched_pdf_id
});
});
await Promise.all(updatePromises);
const results = await Promise.all(updatePromises);
console.log('✅ 모든 문서 업데이트 완료:', results.length, '개');
console.log('✅ 모든 변경사항 저장 완료');
this.showNotification('변경사항이 저장되었습니다', 'success');
@@ -234,7 +288,7 @@ window.bookEditorApp = () => ({
}, 1500);
} catch (error) {
console.error('저장 실패:', error);
console.error('저장 실패:', error);
this.showNotification('저장에 실패했습니다: ' + error.message, 'error');
} finally {
this.saving = false;

View File

@@ -195,12 +195,24 @@ document.addEventListener('headerLoaded', () => {
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
// 관리자 메뉴 표시/숨김
console.log('🔍 사용자 권한 확인:', {
role: user.role,
is_admin: user.is_admin,
can_manage_books: user.can_manage_books,
can_manage_notes: user.can_manage_notes,
can_manage_novels: user.can_manage_novels
});
if (adminMenuSection) {
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
console.log('✅ 관리자 메뉴 표시');
adminMenuSection.classList.remove('hidden');
} else {
console.log('❌ 관리자 메뉴 숨김');
adminMenuSection.classList.add('hidden');
}
} else {
console.log('❌ adminMenuSection 요소를 찾을 수 없음');
}
} else {
// 로그아웃된 상태

View File

@@ -1020,6 +1020,28 @@ window.documentViewer = () => ({
pdf_path: this.document.pdf_path
});
// 캐시를 무시하고 최신 문서 정보를 다시 가져오기
console.log('🔄 최신 문서 정보 재로드 중...');
try {
const freshDocument = await this.api.getDocument(this.document.id);
console.log('📄 최신 문서 정보:', {
id: freshDocument.id,
matched_pdf_id: freshDocument.matched_pdf_id,
pdf_path: freshDocument.pdf_path
});
// 최신 정보로 업데이트
if (freshDocument.matched_pdf_id !== this.document.matched_pdf_id) {
console.log('🔄 PDF 매칭 정보 업데이트:', {
old: this.document.matched_pdf_id,
new: freshDocument.matched_pdf_id
});
this.document.matched_pdf_id = freshDocument.matched_pdf_id;
}
} catch (error) {
console.error('❌ 최신 문서 정보 로드 실패:', error);
}
// 1. 현재 문서 자체가 PDF인 경우
if (this.document.pdf_path) {
console.log('📄 현재 문서가 PDF - 직접 다운로드');