企業內部新聞彙整與分析系統 - 自動新聞抓取 (Digitimes, 經濟日報, 工商時報) - AI 智慧摘要 (OpenAI/Claude/Ollama) - 群組管理與訂閱通知 - 已清理 Python 快取檔案 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1011 lines
33 KiB
JavaScript
1011 lines
33 KiB
JavaScript
/**
|
|
* 每日報導 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 = '<tr><td colspan="5" style="text-align: center; color: #7f8c8d;">今日尚無報告</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = reports.map(report => `
|
|
<tr>
|
|
<td>${this.escapeHtml(report.title)}</td>
|
|
<td>${this.escapeHtml(report.group?.name || '-')}</td>
|
|
<td>${report.article_count || 0}</td>
|
|
<td>${this.renderStatusBadge(report.status)}</td>
|
|
<td>
|
|
<button class="btn btn-primary btn-sm" onclick="app.viewReport(${report.id})">
|
|
${report.status === 'published' ? '查看' : '審核'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).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 = '<div class="empty-state">暫無報告</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = reports.map(report => `
|
|
<div class="card report-card" data-report-id="${report.id}">
|
|
<h3 style="margin-bottom: 1rem;">${this.escapeHtml(report.title)}</h3>
|
|
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
|
|
<span class="badge badge-info">${this.escapeHtml(report.group?.name || '-')}</span>
|
|
${this.renderStatusBadge(report.status)}
|
|
<span>${report.article_count || 0} 篇文章</span>
|
|
</div>
|
|
<div style="display: flex; gap: 1rem;">
|
|
<button class="btn btn-primary" onclick="app.viewReport(${report.id})">查看詳情</button>
|
|
${report.status !== 'published' ? `
|
|
<button class="btn btn-success" onclick="app.publishReport(${report.id})">發布報告</button>
|
|
` : ''}
|
|
<button class="btn btn-secondary" onclick="app.exportReport(${report.id})">匯出 PDF</button>
|
|
</div>
|
|
</div>
|
|
`).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 = `
|
|
<h2>${this.escapeHtml(report.title)}</h2>
|
|
<div style="display: flex; gap: 1rem; margin: 1rem 0;">
|
|
<span class="badge badge-info">${this.escapeHtml(report.group?.name || '-')}</span>
|
|
${this.renderStatusBadge(report.status)}
|
|
<span>報告日期: ${report.report_date}</span>
|
|
</div>
|
|
|
|
<div class="summary-box">
|
|
<h4 style="margin-bottom: 0.5rem;">AI 摘要</h4>
|
|
<p id="report-summary-text">${this.escapeHtml(summary)}</p>
|
|
</div>
|
|
|
|
<div style="margin-top: 1.5rem;">
|
|
<h4 style="margin-bottom: 1rem;">相關新聞 (${report.articles?.length || 0} 篇)</h4>
|
|
<div id="report-articles-list">
|
|
${(report.articles || []).map(article => `
|
|
<div class="article-item">
|
|
<div class="article-info">
|
|
<div class="article-title">${this.escapeHtml(article.title)}</div>
|
|
<div class="article-meta">${this.escapeHtml(article.source_name || '-')} | ${this.formatDate(article.published_at)}</div>
|
|
</div>
|
|
<label class="switch">
|
|
<input type="checkbox" ${article.is_included ? 'checked' : ''}
|
|
onchange="app.toggleArticle(${report.id}, ${article.id}, this.checked)">
|
|
<span class="slider"></span>
|
|
</label>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
|
|
<button class="btn btn-primary" onclick="app.editSummary(${report.id})">編輯摘要</button>
|
|
<button class="btn btn-secondary" onclick="app.regenerateSummary(${report.id})">重新產生摘要</button>
|
|
${report.status !== 'published' ? `
|
|
<button class="btn btn-success" onclick="app.publishReport(${report.id})">發布報告</button>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
// 儲存當前報告 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 = `
|
|
<textarea id="edit-summary-textarea" class="form-control" rows="6">${this.escapeHtml(currentText)}</textarea>
|
|
<div style="margin-top: 1rem;">
|
|
<button class="btn btn-success" onclick="app.saveSummary(${reportId})">儲存</button>
|
|
<button class="btn btn-secondary" onclick="app.viewReport(${reportId})">取消</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 儲存摘要
|
|
*/
|
|
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 = '<option value="all">全部群組</option>' +
|
|
groups.map(g => `<option value="${g.id}">${this.escapeHtml(g.name)}</option>`).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 = '<tr><td colspan="6" style="text-align: center;">暫無群組</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = groups.map(group => `
|
|
<tr>
|
|
<td>${this.escapeHtml(group.name)}</td>
|
|
<td>${group.category === 'industry' ? '產業別' : '主題'}</td>
|
|
<td>${group.keyword_count || 0}</td>
|
|
<td>${group.subscriber_count || 0}</td>
|
|
<td>${group.is_active ? '<span class="badge badge-success">啟用</span>' : '<span class="badge badge-danger">停用</span>'}</td>
|
|
<td>
|
|
<button class="btn btn-primary btn-sm" onclick="app.editGroup(${group.id})">編輯</button>
|
|
<button class="btn btn-danger btn-sm" onclick="app.deleteGroup(${group.id})">刪除</button>
|
|
</td>
|
|
</tr>
|
|
`).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 => `
|
|
<span class="badge badge-info keyword-badge" data-id="${kw.id}">
|
|
${this.escapeHtml(kw.keyword)}
|
|
<button type="button" onclick="app.removeKeyword(${group.id}, ${kw.id}, this)">×</button>
|
|
</span>
|
|
`).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 = '<tr><td colspan="7" style="text-align: center;">暫無用戶</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = users.map(user => `
|
|
<tr>
|
|
<td>${this.escapeHtml(user.username)}</td>
|
|
<td>${this.escapeHtml(user.display_name)}</td>
|
|
<td>${this.escapeHtml(user.email || '-')}</td>
|
|
<td>${this.renderRoleBadge(user.role?.code || user.role)}</td>
|
|
<td>${user.auth_type === 'ad' ? 'AD' : '本地'}</td>
|
|
<td>${user.is_active ? '<span class="badge badge-success">啟用</span>' : '<span class="badge badge-danger">停用</span>'}</td>
|
|
<td>
|
|
<button class="btn btn-primary btn-sm" onclick="app.editUser(${user.id})">編輯</button>
|
|
</td>
|
|
</tr>
|
|
`).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 `<span class="badge ${s.class}">${s.text}</span>`;
|
|
}
|
|
|
|
/**
|
|
* 渲染角色標籤
|
|
*/
|
|
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 `<span class="badge ${r.class}">${r.text}</span>`;
|
|
}
|
|
|
|
/**
|
|
* 格式化日期
|
|
*/
|
|
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();
|
|
});
|