/**
* 每日報導 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();
});