/** * 每日報導 APP - 主應用程式 */ class App { constructor() { this.currentPage = 'dashboard'; this.currentUser = null; this.isLoading = false; } /** * 初始化應用程式 */ async init() { // 檢查登入狀態 if (!authApi.isLoggedIn()) { this.showLoginPage(); return; } try { // 驗證 Token 有效性 this.currentUser = await authApi.getMe(); localStorage.setItem('user', JSON.stringify(this.currentUser)); this.showMainApp(); await this.loadDashboard(); } catch (error) { console.error('Token 驗證失敗:', error); api.clearToken(); this.showLoginPage(); } } /** * 顯示登入頁面 */ showLoginPage() { document.getElementById('login-page').style.display = 'flex'; document.getElementById('main-app').style.display = 'none'; } /** * 顯示主應用程式 */ showMainApp() { document.getElementById('login-page').style.display = 'none'; document.getElementById('main-app').style.display = 'block'; this.updateUserInfo(); this.updateNavByRole(); } /** * 更新用戶資訊顯示 */ updateUserInfo() { const user = this.currentUser || authApi.getUser(); if (user) { document.getElementById('user-display-name').textContent = `${user.display_name} (${user.role?.name || user.role})`; } } /** * 根據角色更新導航 */ updateNavByRole() { const user = this.currentUser || authApi.getUser(); const roleCode = user?.role?.code || user?.role; // 隱藏非權限頁面的標籤 const usersTab = document.querySelector('[data-page="users"]'); const settingsTab = document.querySelector('[data-page="settings"]'); if (roleCode === 'reader') { if (usersTab) usersTab.style.display = 'none'; if (settingsTab) settingsTab.style.display = 'none'; } else if (roleCode === 'editor') { if (usersTab) usersTab.style.display = 'none'; if (settingsTab) settingsTab.style.display = 'none'; } else { if (usersTab) usersTab.style.display = ''; if (settingsTab) settingsTab.style.display = ''; } } /** * 登入處理 */ async handleLogin(username, password, authType) { this.showLoading(true); try { const response = await authApi.login(username, password, authType); this.currentUser = response.user; this.showMainApp(); await this.loadDashboard(); this.showToast('登入成功', 'success'); } catch (error) { this.showToast(error.message || '登入失敗', 'error'); } finally { this.showLoading(false); } } /** * 登出處理 */ async handleLogout() { try { await authApi.logout(); } finally { this.currentUser = null; this.showLoginPage(); this.showToast('已登出', 'success'); } } /** * 切換頁面 */ async showPage(pageId) { // 隱藏所有頁面 document.querySelectorAll('.page').forEach(page => { page.classList.remove('active'); }); // 顯示選中的頁面 const targetPage = document.getElementById(pageId); if (targetPage) { targetPage.classList.add('active'); } // 更新標籤頁狀態 document.querySelectorAll('.tab').forEach(tab => { tab.classList.remove('active'); if (tab.dataset.page === pageId) { tab.classList.add('active'); } }); this.currentPage = pageId; // 載入頁面資料 await this.loadPageData(pageId); } /** * 載入頁面資料 */ async loadPageData(pageId) { this.showLoading(true); try { switch (pageId) { case 'dashboard': await this.loadDashboard(); break; case 'reports': await this.loadReports(); break; case 'groups': await this.loadGroups(); break; case 'users': await this.loadUsers(); break; case 'settings': await this.loadSettings(); break; } } catch (error) { console.error('載入頁面資料失敗:', error); this.showToast('載入資料失敗', 'error'); } finally { this.showLoading(false); } } /** * 載入儀表板 */ async loadDashboard() { const user = this.currentUser || authApi.getUser(); const roleCode = user?.role?.code || user?.role; try { // 載入統計數據 if (roleCode === 'admin') { const dashboard = await settingsApi.getAdminDashboard(); document.getElementById('stat-today-articles').textContent = dashboard.today_articles || 0; document.getElementById('stat-pending-reports').textContent = dashboard.pending_reports || 0; document.getElementById('stat-active-users').textContent = dashboard.active_users || 0; } // 載入今日報告 const todayReports = await reportsApi.getToday(); this.renderDashboardReports(todayReports); } catch (error) { console.error('載入儀表板失敗:', error); } } /** * 渲染儀表板報告 */ renderDashboardReports(reports) { const tbody = document.getElementById('dashboard-reports-tbody'); if (!tbody) return; if (!reports || reports.length === 0) { tbody.innerHTML = '今日尚無報告'; return; } tbody.innerHTML = reports.map(report => ` ${this.escapeHtml(report.title)} ${this.escapeHtml(report.group?.name || '-')} ${report.article_count || 0} ${this.renderStatusBadge(report.status)} `).join(''); } /** * 載入報告列表 */ async loadReports() { try { const params = { page: 1, limit: 20 }; const dateFilter = document.getElementById('report-date-filter')?.value; const groupFilter = document.getElementById('report-group-filter')?.value; if (dateFilter) params.date_from = dateFilter; if (groupFilter && groupFilter !== 'all') params.group_id = groupFilter; const response = await reportsApi.getList(params); this.renderReportsList(response.data || response); // 載入群組選項 await this.loadGroupOptions(); } catch (error) { console.error('載入報告失敗:', error); } } /** * 渲染報告列表 */ renderReportsList(reports) { const container = document.getElementById('reports-list'); if (!container) return; if (!reports || reports.length === 0) { container.innerHTML = '
暫無報告
'; return; } container.innerHTML = reports.map(report => `

${this.escapeHtml(report.title)}

${this.escapeHtml(report.group?.name || '-')} ${this.renderStatusBadge(report.status)} ${report.article_count || 0} 篇文章
${report.status !== 'published' ? ` ` : ''}
`).join(''); } /** * 查看報告詳情 */ async viewReport(reportId) { this.showLoading(true); try { const report = await reportsApi.getById(reportId); this.renderReportDetail(report); document.getElementById('report-detail-modal').style.display = 'flex'; } catch (error) { this.showToast('載入報告失敗', 'error'); } finally { this.showLoading(false); } } /** * 渲染報告詳情 */ renderReportDetail(report) { const container = document.getElementById('report-detail-content'); if (!container) return; const summary = report.edited_summary || report.ai_summary || '尚無摘要'; container.innerHTML = `

${this.escapeHtml(report.title)}

${this.escapeHtml(report.group?.name || '-')} ${this.renderStatusBadge(report.status)} 報告日期: ${report.report_date}

AI 摘要

${this.escapeHtml(summary)}

相關新聞 (${report.articles?.length || 0} 篇)

${(report.articles || []).map(article => `
`).join('')}
${report.status !== 'published' ? ` ` : ''}
`; // 儲存當前報告 ID container.dataset.reportId = report.id; } /** * 切換文章包含狀態 */ async toggleArticle(reportId, articleId, isIncluded) { try { await reportsApi.update(reportId, { article_selections: [{ article_id: articleId, is_included: isIncluded }] }); } catch (error) { this.showToast('更新失敗', 'error'); } } /** * 編輯摘要 */ editSummary(reportId) { const summaryText = document.getElementById('report-summary-text'); const currentText = summaryText.textContent; summaryText.innerHTML = `
`; } /** * 儲存摘要 */ async saveSummary(reportId) { const textarea = document.getElementById('edit-summary-textarea'); const newSummary = textarea.value; this.showLoading(true); try { await reportsApi.update(reportId, { edited_summary: newSummary }); this.showToast('摘要已更新', 'success'); await this.viewReport(reportId); } catch (error) { this.showToast('更新失敗', 'error'); } finally { this.showLoading(false); } } /** * 重新產生摘要 */ async regenerateSummary(reportId) { if (!confirm('確定要重新產生 AI 摘要嗎?')) return; this.showLoading(true); try { const result = await reportsApi.regenerateSummary(reportId); this.showToast('摘要已重新產生', 'success'); await this.viewReport(reportId); } catch (error) { this.showToast('產生摘要失敗', 'error'); } finally { this.showLoading(false); } } /** * 發布報告 */ async publishReport(reportId) { if (!confirm('確定要發布此報告嗎?')) return; this.showLoading(true); try { const result = await reportsApi.publish(reportId); this.showToast(`報告已發布,已通知 ${result.notifications_sent || 0} 位訂閱者`, 'success'); this.closeModal('report-detail-modal'); await this.loadReports(); } catch (error) { this.showToast('發布失敗', 'error'); } finally { this.showLoading(false); } } /** * 匯出報告 */ async exportReport(reportId) { this.showLoading(true); try { const blob = await reportsApi.exportPdf(reportId); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `report_${reportId}.pdf`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); this.showToast('匯出成功', 'success'); } catch (error) { this.showToast('匯出失敗', 'error'); } finally { this.showLoading(false); } } /** * 載入群組選項 */ async loadGroupOptions() { try { const response = await groupsApi.getList({ limit: 100 }); const groups = response.data || response; const select = document.getElementById('report-group-filter'); if (select) { select.innerHTML = '' + groups.map(g => ``).join(''); } } catch (error) { console.error('載入群組選項失敗:', error); } } /** * 載入群組列表 */ async loadGroups() { try { const response = await groupsApi.getList({ limit: 100 }); const groups = response.data || response; this.renderGroupsList(groups); } catch (error) { console.error('載入群組失敗:', error); } } /** * 渲染群組列表 */ renderGroupsList(groups) { const tbody = document.getElementById('groups-tbody'); if (!tbody) return; if (!groups || groups.length === 0) { tbody.innerHTML = '暫無群組'; return; } tbody.innerHTML = groups.map(group => ` ${this.escapeHtml(group.name)} ${group.category === 'industry' ? '產業別' : '主題'} ${group.keyword_count || 0} ${group.subscriber_count || 0} ${group.is_active ? '啟用' : '停用'} `).join(''); } /** * 編輯群組 */ async editGroup(groupId) { this.showLoading(true); try { const group = await groupsApi.getById(groupId); this.renderGroupForm(group); document.getElementById('group-form-card').style.display = 'block'; } catch (error) { this.showToast('載入群組失敗', 'error'); } finally { this.showLoading(false); } } /** * 渲染群組表單 */ renderGroupForm(group = null) { const form = document.getElementById('group-form'); if (!form) return; form.dataset.groupId = group?.id || ''; document.getElementById('group-name').value = group?.name || ''; document.getElementById('group-description').value = group?.description || ''; document.getElementById('group-category').value = group?.category || 'industry'; document.getElementById('group-ai-background').value = group?.ai_background || ''; document.getElementById('group-ai-prompt').value = group?.ai_prompt || ''; // 渲染關鍵字 const keywordsContainer = document.getElementById('group-keywords'); if (keywordsContainer && group?.keywords) { keywordsContainer.innerHTML = group.keywords.map(kw => ` ${this.escapeHtml(kw.keyword)} `).join(''); } } /** * 新增群組 */ showNewGroupForm() { this.renderGroupForm(null); document.getElementById('group-form-card').style.display = 'block'; } /** * 儲存群組 */ async saveGroup() { const form = document.getElementById('group-form'); const groupId = form.dataset.groupId; const groupData = { name: document.getElementById('group-name').value, description: document.getElementById('group-description').value, category: document.getElementById('group-category').value, ai_background: document.getElementById('group-ai-background').value, ai_prompt: document.getElementById('group-ai-prompt').value }; this.showLoading(true); try { if (groupId) { await groupsApi.update(groupId, groupData); this.showToast('群組已更新', 'success'); } else { await groupsApi.create(groupData); this.showToast('群組已建立', 'success'); } document.getElementById('group-form-card').style.display = 'none'; await this.loadGroups(); } catch (error) { this.showToast(error.message || '儲存失敗', 'error'); } finally { this.showLoading(false); } } /** * 刪除群組 */ async deleteGroup(groupId) { if (!confirm('確定要刪除此群組嗎?此操作無法復原。')) return; this.showLoading(true); try { await groupsApi.delete(groupId); this.showToast('群組已刪除', 'success'); await this.loadGroups(); } catch (error) { this.showToast('刪除失敗', 'error'); } finally { this.showLoading(false); } } /** * 新增關鍵字 */ async addKeyword(groupId) { const input = document.getElementById('new-keyword-input'); const keyword = input.value.trim(); if (!keyword) return; this.showLoading(true); try { await groupsApi.addKeyword(groupId, keyword); input.value = ''; await this.editGroup(groupId); this.showToast('關鍵字已新增', 'success'); } catch (error) { this.showToast('新增失敗', 'error'); } finally { this.showLoading(false); } } /** * 移除關鍵字 */ async removeKeyword(groupId, keywordId, button) { this.showLoading(true); try { await groupsApi.deleteKeyword(groupId, keywordId); button.parentElement.remove(); this.showToast('關鍵字已移除', 'success'); } catch (error) { this.showToast('移除失敗', 'error'); } finally { this.showLoading(false); } } /** * 載入用戶列表 */ async loadUsers() { try { const params = { page: 1, limit: 20 }; const roleFilter = document.getElementById('user-role-filter')?.value; const searchTerm = document.getElementById('user-search')?.value; if (roleFilter && roleFilter !== 'all') params.role = roleFilter; if (searchTerm) params.search = searchTerm; const response = await usersApi.getList(params); this.renderUsersList(response.data || response); } catch (error) { console.error('載入用戶失敗:', error); } } /** * 渲染用戶列表 */ renderUsersList(users) { const tbody = document.getElementById('users-tbody'); if (!tbody) return; if (!users || users.length === 0) { tbody.innerHTML = '暫無用戶'; return; } tbody.innerHTML = users.map(user => ` ${this.escapeHtml(user.username)} ${this.escapeHtml(user.display_name)} ${this.escapeHtml(user.email || '-')} ${this.renderRoleBadge(user.role?.code || user.role)} ${user.auth_type === 'ad' ? 'AD' : '本地'} ${user.is_active ? '啟用' : '停用'} `).join(''); } /** * 編輯用戶 */ async editUser(userId) { this.showLoading(true); try { const user = await usersApi.getById(userId); this.renderUserForm(user); document.getElementById('user-modal').style.display = 'flex'; } catch (error) { this.showToast('載入用戶失敗', 'error'); } finally { this.showLoading(false); } } /** * 渲染用戶表單 */ renderUserForm(user = null) { const form = document.getElementById('user-form'); if (!form) return; form.dataset.userId = user?.id || ''; document.getElementById('user-username').value = user?.username || ''; document.getElementById('user-display-name-input').value = user?.display_name || ''; document.getElementById('user-email').value = user?.email || ''; document.getElementById('user-role').value = user?.role?.id || user?.role_id || ''; document.getElementById('user-auth-type').value = user?.auth_type || 'local'; document.getElementById('user-is-active').checked = user?.is_active !== false; // 密碼欄位只在新增或本地認證時顯示 const passwordGroup = document.getElementById('user-password-group'); if (passwordGroup) { passwordGroup.style.display = user?.auth_type === 'ad' ? 'none' : 'block'; } } /** * 顯示新增用戶表單 */ showNewUserForm() { this.renderUserForm(null); document.getElementById('user-modal').style.display = 'flex'; } /** * 儲存用戶 */ async saveUser() { const form = document.getElementById('user-form'); const userId = form.dataset.userId; const userData = { username: document.getElementById('user-username').value, display_name: document.getElementById('user-display-name-input').value, email: document.getElementById('user-email').value, role_id: parseInt(document.getElementById('user-role').value), auth_type: document.getElementById('user-auth-type').value, is_active: document.getElementById('user-is-active').checked }; const password = document.getElementById('user-password')?.value; if (password) { userData.password = password; } this.showLoading(true); try { if (userId) { await usersApi.update(userId, userData); this.showToast('用戶已更新', 'success'); } else { await usersApi.create(userData); this.showToast('用戶已建立', 'success'); } this.closeModal('user-modal'); await this.loadUsers(); } catch (error) { this.showToast(error.message || '儲存失敗', 'error'); } finally { this.showLoading(false); } } /** * 載入系統設定 */ async loadSettings() { try { const settings = await settingsApi.get(); this.renderSettings(settings); } catch (error) { console.error('載入設定失敗:', error); } } /** * 渲染系統設定 */ renderSettings(settings) { // LLM 設定 document.getElementById('llm-provider').value = settings.llm_provider || 'gemini'; document.getElementById('llm-model').value = settings.llm_model || ''; // PDF 設定 document.getElementById('pdf-header').value = settings.pdf_header_text || ''; document.getElementById('pdf-footer').value = settings.pdf_footer_text || ''; // SMTP 設定 document.getElementById('smtp-host').value = settings.smtp_host || ''; document.getElementById('smtp-port').value = settings.smtp_port || 587; document.getElementById('smtp-username').value = settings.smtp_username || ''; document.getElementById('smtp-from-email').value = settings.smtp_from_email || ''; document.getElementById('smtp-from-name').value = settings.smtp_from_name || ''; } /** * 儲存 LLM 設定 */ async saveLlmSettings() { const settings = { llm_provider: document.getElementById('llm-provider').value, llm_model: document.getElementById('llm-model').value, llm_api_key: document.getElementById('llm-api-key').value }; this.showLoading(true); try { await settingsApi.update(settings); this.showToast('LLM 設定已儲存', 'success'); } catch (error) { this.showToast('儲存失敗', 'error'); } finally { this.showLoading(false); } } /** * 測試 LLM 連線 */ async testLlmConnection() { this.showLoading(true); try { const result = await settingsApi.testLlm(); if (result.success) { this.showToast(`連線成功!回應時間: ${result.response_time_ms}ms`, 'success'); } else { this.showToast('連線失敗', 'error'); } } catch (error) { this.showToast('測試失敗', 'error'); } finally { this.showLoading(false); } } /** * 儲存 SMTP 設定 */ async saveSmtpSettings() { const settings = { smtp_host: document.getElementById('smtp-host').value, smtp_port: parseInt(document.getElementById('smtp-port').value), smtp_username: document.getElementById('smtp-username').value, smtp_password: document.getElementById('smtp-password').value, smtp_from_email: document.getElementById('smtp-from-email').value, smtp_from_name: document.getElementById('smtp-from-name').value }; this.showLoading(true); try { await settingsApi.update(settings); this.showToast('SMTP 設定已儲存', 'success'); } catch (error) { this.showToast('儲存失敗', 'error'); } finally { this.showLoading(false); } } /** * 上傳 Logo */ async uploadLogo() { const input = document.getElementById('pdf-logo-input'); const file = input.files[0]; if (!file) { this.showToast('請選擇檔案', 'error'); return; } this.showLoading(true); try { const result = await settingsApi.uploadLogo(file); this.showToast('Logo 已上傳', 'success'); } catch (error) { this.showToast('上傳失敗', 'error'); } finally { this.showLoading(false); } } // ============ 工具方法 ============ /** * 關閉 Modal */ closeModal(modalId) { const modal = document.getElementById(modalId); if (modal) { modal.style.display = 'none'; } } /** * 顯示/隱藏載入中 */ showLoading(show) { this.isLoading = show; const loader = document.getElementById('loading-overlay'); if (loader) { loader.style.display = show ? 'flex' : 'none'; } } /** * 顯示 Toast 訊息 */ showToast(message, type = 'info') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; const container = document.getElementById('toast-container') || document.body; container.appendChild(toast); setTimeout(() => { toast.classList.add('show'); }, 10); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000); } /** * 渲染狀態標籤 */ renderStatusBadge(status) { const statusMap = { 'draft': { class: 'badge-secondary', text: '草稿' }, 'pending': { class: 'badge-warning', text: '待審核' }, 'published': { class: 'badge-success', text: '已發布' }, 'delayed': { class: 'badge-danger', text: '延遲' } }; const s = statusMap[status] || { class: 'badge-secondary', text: status }; return `${s.text}`; } /** * 渲染角色標籤 */ renderRoleBadge(roleCode) { const roleMap = { 'admin': { class: 'badge-danger', text: '管理員' }, 'editor': { class: 'badge-warning', text: '專員' }, 'reader': { class: 'badge-info', text: '讀者' } }; const r = roleMap[roleCode] || { class: 'badge-secondary', text: roleCode }; return `${r.text}`; } /** * 格式化日期 */ formatDate(dateStr) { if (!dateStr) return '-'; const date = new Date(dateStr); return date.toLocaleString('zh-TW'); } /** * HTML 跳脫 */ escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } // 建立全域應用程式實例 const app = new App(); // 頁面載入完成後初始化 document.addEventListener('DOMContentLoaded', () => { app.init(); });