Initial commit: Daily News App
企業內部新聞彙整與分析系統 - 自動新聞抓取 (Digitimes, 經濟日報, 工商時報) - AI 智慧摘要 (OpenAI/Claude/Ollama) - 群組管理與訂閱通知 - 已清理 Python 快取檔案 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
949
templates/index.html
Normal file
949
templates/index.html
Normal file
@@ -0,0 +1,949 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>每日報導 APP</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft JhengHei", Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 登入頁面 */
|
||||
#login-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 3rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
/* 導航列 */
|
||||
.navbar {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.navbar-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 按鈕 */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #219a52;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* 容器 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 標籤 */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* 表單 */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
/* 頁面切換 */
|
||||
.page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 標籤頁 */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 1rem 1.5rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1rem;
|
||||
color: #7f8c8d;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #3498db;
|
||||
border-bottom-color: #3498db;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 統計卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* 摘要區塊 */
|
||||
.summary-box {
|
||||
background: #f8f9fa;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #3498db;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* 文章列表 */
|
||||
.article-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.article-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.article-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 切換按鈕 */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #3498db;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
#loading-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.8);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #ecf0f1;
|
||||
border-top-color: #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: #27ae60;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
/* 關鍵字標籤 */
|
||||
.keyword-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.keyword-badge button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 空狀態 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
/* 報告卡片 */
|
||||
.report-card {
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
/* 響應式 */
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 分隔線 */
|
||||
.section-divider {
|
||||
margin: 2rem 0;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #ecf0f1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- 登入頁面 -->
|
||||
<div id="login-page">
|
||||
<div class="login-card">
|
||||
<h1>每日報導 APP</h1>
|
||||
<form id="login-form" onsubmit="handleLoginSubmit(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">帳號</label>
|
||||
<input type="text" class="form-control" id="login-username" required placeholder="輸入帳號">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="login-password" required placeholder="輸入密碼">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">認證方式</label>
|
||||
<select class="form-control" id="login-auth-type">
|
||||
<option value="local">本地認證</option>
|
||||
<option value="ad">AD 認證</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.75rem;">登入</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主應用程式 -->
|
||||
<div id="main-app" style="display: none;">
|
||||
<!-- 導航列 -->
|
||||
<nav class="navbar">
|
||||
<h1>每日報導 APP</h1>
|
||||
<div class="navbar-actions">
|
||||
<div class="user-menu">
|
||||
<span id="user-display-name">載入中...</span>
|
||||
<button class="btn btn-secondary" onclick="app.handleLogout()">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<!-- 頁面選單 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-page="dashboard" onclick="app.showPage('dashboard')">儀表板</button>
|
||||
<button class="tab" data-page="reports" onclick="app.showPage('reports')">報告管理</button>
|
||||
<button class="tab" data-page="groups" onclick="app.showPage('groups')">群組管理</button>
|
||||
<button class="tab" data-page="users" onclick="app.showPage('users')">用戶管理</button>
|
||||
<button class="tab" data-page="settings" onclick="app.showPage('settings')">系統設定</button>
|
||||
</div>
|
||||
|
||||
<!-- 儀表板頁面 -->
|
||||
<div id="dashboard" class="page active">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-today-articles">-</div>
|
||||
<div class="stat-label">今日新聞</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-pending-reports">-</div>
|
||||
<div class="stat-label">待審核報告</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-published-reports">-</div>
|
||||
<div class="stat-label">已發布報告</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-active-users">-</div>
|
||||
<div class="stat-label">活躍用戶</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">今日待審核報告</h2>
|
||||
<button class="btn btn-primary" onclick="app.showPage('reports')">查看全部</button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>報告標題</th>
|
||||
<th>群組</th>
|
||||
<th>文章數</th>
|
||||
<th>狀態</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashboard-reports-tbody">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: #7f8c8d;">載入中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 報告管理頁面 -->
|
||||
<div id="reports" class="page">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">報告管理</h2>
|
||||
<div>
|
||||
<input type="date" class="form-control" id="report-date-filter"
|
||||
style="width: auto; display: inline-block; margin-right: 0.5rem;"
|
||||
onchange="app.loadReports()">
|
||||
<select class="form-control" id="report-group-filter"
|
||||
style="width: auto; display: inline-block;"
|
||||
onchange="app.loadReports()">
|
||||
<option value="all">全部群組</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reports-list">
|
||||
<div class="empty-state">載入中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群組管理頁面 -->
|
||||
<div id="groups" class="page">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">群組管理</h2>
|
||||
<button class="btn btn-primary" onclick="app.showNewGroupForm()">新增群組</button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>群組名稱</th>
|
||||
<th>分類</th>
|
||||
<th>關鍵字數</th>
|
||||
<th>訂閱數</th>
|
||||
<th>狀態</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-tbody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center;">載入中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 群組編輯表單 -->
|
||||
<div class="card" id="group-form-card" style="display: none;">
|
||||
<h3 class="card-title" style="margin-bottom: 1rem;">編輯群組</h3>
|
||||
<form id="group-form" onsubmit="event.preventDefault(); app.saveGroup();">
|
||||
<div class="form-group">
|
||||
<label class="form-label">群組名稱</label>
|
||||
<input type="text" class="form-control" id="group-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea class="form-control" id="group-description" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">分類</label>
|
||||
<select class="form-control" id="group-category">
|
||||
<option value="industry">產業別</option>
|
||||
<option value="topic">主題</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">關鍵字</label>
|
||||
<div id="group-keywords" style="margin-bottom: 0.5rem;"></div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" class="form-control" id="new-keyword-input" placeholder="新增關鍵字...">
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick="app.addKeyword(document.getElementById('group-form').dataset.groupId)">新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">AI 摘要背景資訊</label>
|
||||
<textarea class="form-control" id="group-ai-background" rows="4"
|
||||
placeholder="提供給 AI 的背景資訊..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">AI 摘要方向</label>
|
||||
<textarea class="form-control" id="group-ai-prompt" rows="4"
|
||||
placeholder="指定 AI 摘要的重點方向..."></textarea>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<button type="submit" class="btn btn-primary">儲存</button>
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="document.getElementById('group-form-card').style.display='none'">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用戶管理頁面 -->
|
||||
<div id="users" class="page">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">用戶管理</h2>
|
||||
<button class="btn btn-primary" onclick="app.showNewUserForm()">新增用戶</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<input type="text" class="form-control" id="user-search" placeholder="搜尋用戶..."
|
||||
style="max-width: 300px; display: inline-block;" onkeyup="app.loadUsers()">
|
||||
<select class="form-control" id="user-role-filter"
|
||||
style="width: auto; display: inline-block; margin-left: 0.5rem;"
|
||||
onchange="app.loadUsers()">
|
||||
<option value="all">全部角色</option>
|
||||
<option value="admin">管理員</option>
|
||||
<option value="editor">專員</option>
|
||||
<option value="reader">讀者</option>
|
||||
</select>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>帳號</th>
|
||||
<th>顯示名稱</th>
|
||||
<th>Email</th>
|
||||
<th>角色</th>
|
||||
<th>認證方式</th>
|
||||
<th>狀態</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody">
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center;">載入中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系統設定頁面 -->
|
||||
<div id="settings" class="page">
|
||||
<div class="card">
|
||||
<h2 class="card-title" style="margin-bottom: 1.5rem;">系統設定</h2>
|
||||
|
||||
<!-- LLM 設定 -->
|
||||
<div>
|
||||
<h3 style="margin-bottom: 1rem; color: #2c3e50;">LLM 設定</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">LLM 提供者</label>
|
||||
<select class="form-control" id="llm-provider">
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="ollama">Ollama (地端)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">API Key</label>
|
||||
<input type="password" class="form-control" id="llm-api-key" placeholder="輸入 API Key...">
|
||||
<small style="color: #7f8c8d; margin-top: 0.25rem; display: block;">API Key 將加密儲存</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">模型版本</label>
|
||||
<input type="text" class="form-control" id="llm-model" placeholder="例如: gemini-1.5-pro">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="app.testLlmConnection()">測試連線</button>
|
||||
<button class="btn btn-success" style="margin-left: 0.5rem;" onclick="app.saveLlmSettings()">儲存設定</button>
|
||||
</div>
|
||||
|
||||
<!-- PDF 模板設定 -->
|
||||
<div class="section-divider">
|
||||
<h3 style="margin-bottom: 1rem; color: #2c3e50;">PDF 模板設定</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">公司 Logo</label>
|
||||
<input type="file" class="form-control" id="pdf-logo-input" accept="image/png,image/jpeg,image/svg+xml">
|
||||
<small style="color: #7f8c8d; margin-top: 0.25rem; display: block;">支援 PNG、JPEG、SVG 格式</small>
|
||||
<button class="btn btn-primary" style="margin-top: 0.5rem;" onclick="app.uploadLogo()">上傳</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">頁首文字</label>
|
||||
<input type="text" class="form-control" id="pdf-header" placeholder="每日報導">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">頁尾文字</label>
|
||||
<input type="text" class="form-control" id="pdf-footer" placeholder="本報告僅供內部參考使用">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMTP 設定 -->
|
||||
<div class="section-divider">
|
||||
<h3 style="margin-bottom: 1rem; color: #2c3e50;">SMTP 設定</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SMTP 伺服器</label>
|
||||
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SMTP 埠號</label>
|
||||
<input type="number" class="form-control" id="smtp-port" value="587">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SMTP 帳號</label>
|
||||
<input type="text" class="form-control" id="smtp-username" placeholder="smtp@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SMTP 密碼</label>
|
||||
<input type="password" class="form-control" id="smtp-password" placeholder="輸入密碼...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">寄件者 Email</label>
|
||||
<input type="email" class="form-control" id="smtp-from-email" placeholder="noreply@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">寄件者名稱</label>
|
||||
<input type="text" class="form-control" id="smtp-from-name" placeholder="每日報導系統">
|
||||
</div>
|
||||
<button class="btn btn-success" onclick="app.saveSmtpSettings()">儲存設定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 報告詳情 Modal -->
|
||||
<div class="modal" id="report-detail-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>報告詳情</h2>
|
||||
<button class="modal-close" onclick="app.closeModal('report-detail-modal')">×</button>
|
||||
</div>
|
||||
<div id="report-detail-content">
|
||||
載入中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用戶編輯 Modal -->
|
||||
<div class="modal" id="user-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>編輯用戶</h2>
|
||||
<button class="modal-close" onclick="app.closeModal('user-modal')">×</button>
|
||||
</div>
|
||||
<form id="user-form" onsubmit="event.preventDefault(); app.saveUser();">
|
||||
<div class="form-group">
|
||||
<label class="form-label">帳號</label>
|
||||
<input type="text" class="form-control" id="user-username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">顯示名稱</label>
|
||||
<input type="text" class="form-control" id="user-display-name-input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="user-email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">角色</label>
|
||||
<select class="form-control" id="user-role">
|
||||
<option value="1">管理員</option>
|
||||
<option value="2">專員</option>
|
||||
<option value="3">讀者</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">認證方式</label>
|
||||
<select class="form-control" id="user-auth-type">
|
||||
<option value="local">本地認證</option>
|
||||
<option value="ad">AD 認證</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="user-password-group">
|
||||
<label class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="user-password" placeholder="留空則不修改">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" id="user-is-active" checked> 啟用帳號
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<button type="submit" class="btn btn-primary">儲存</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="app.closeModal('user-modal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 載入 JavaScript -->
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
// 登入表單處理
|
||||
function handleLoginSubmit(event) {
|
||||
event.preventDefault();
|
||||
const username = document.getElementById('login-username').value;
|
||||
const password = document.getElementById('login-password').value;
|
||||
const authType = document.getElementById('login-auth-type').value;
|
||||
app.handleLogin(username, password, authType);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
430
templates/js/api.js
Normal file
430
templates/js/api.js
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* API 服務層 - 處理所有後端 API 呼叫
|
||||
*/
|
||||
|
||||
const API_BASE_URL = '/api/v1';
|
||||
|
||||
class ApiService {
|
||||
constructor() {
|
||||
this.baseUrl = API_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得認證 Token
|
||||
*/
|
||||
getToken() {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定認證 Token
|
||||
*/
|
||||
setToken(token) {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除認證 Token
|
||||
*/
|
||||
clearToken() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立請求標頭
|
||||
*/
|
||||
getHeaders(includeAuth = true) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (includeAuth) {
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 發送 API 請求
|
||||
*/
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const config = {
|
||||
headers: this.getHeaders(options.auth !== false),
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// 處理 401 未授權
|
||||
if (response.status === 401) {
|
||||
this.clearToken();
|
||||
window.location.reload();
|
||||
throw new Error('登入已過期,請重新登入');
|
||||
}
|
||||
|
||||
// 處理 204 無內容
|
||||
if (response.status === 204) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || '請求失敗');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 請求
|
||||
*/
|
||||
async get(endpoint, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
return this.request(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 請求
|
||||
*/
|
||||
async post(endpoint, data = {}, options = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 請求
|
||||
*/
|
||||
async put(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 請求
|
||||
*/
|
||||
async delete(endpoint) {
|
||||
return this.request(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 上傳檔案
|
||||
*/
|
||||
async upload(endpoint, formData) {
|
||||
const token = this.getToken();
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || '上傳失敗');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
// 建立全域 API 實例 (必須在其他 API 模組之前)
|
||||
const api = new ApiService();
|
||||
|
||||
// ============ 認證 API ============
|
||||
const authApi = {
|
||||
/**
|
||||
* 登入
|
||||
*/
|
||||
async login(username, password, authType = 'local') {
|
||||
const response = await api.post('/auth/login', {
|
||||
username,
|
||||
password,
|
||||
auth_type: authType
|
||||
}, { auth: false });
|
||||
|
||||
if (response.token) {
|
||||
api.setToken(response.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
api.clearToken();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得當前用戶
|
||||
*/
|
||||
async getMe() {
|
||||
return api.get('/auth/me');
|
||||
},
|
||||
|
||||
/**
|
||||
* 檢查是否已登入
|
||||
*/
|
||||
isLoggedIn() {
|
||||
return !!api.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得本地儲存的用戶資訊
|
||||
*/
|
||||
getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
return user ? JSON.parse(user) : null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 用戶管理 API ============
|
||||
const usersApi = {
|
||||
/**
|
||||
* 取得用戶列表
|
||||
*/
|
||||
async getList(params = {}) {
|
||||
return api.get('/users', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得單一用戶
|
||||
*/
|
||||
async getById(userId) {
|
||||
return api.get(`/users/${userId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 建立用戶
|
||||
*/
|
||||
async create(userData) {
|
||||
return api.post('/users', userData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用戶
|
||||
*/
|
||||
async update(userId, userData) {
|
||||
return api.put(`/users/${userId}`, userData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除用戶
|
||||
*/
|
||||
async delete(userId) {
|
||||
return api.delete(`/users/${userId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 群組管理 API ============
|
||||
const groupsApi = {
|
||||
/**
|
||||
* 取得群組列表
|
||||
*/
|
||||
async getList(params = {}) {
|
||||
return api.get('/groups', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得群組詳情
|
||||
*/
|
||||
async getById(groupId) {
|
||||
return api.get(`/groups/${groupId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 建立群組
|
||||
*/
|
||||
async create(groupData) {
|
||||
return api.post('/groups', groupData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新群組
|
||||
*/
|
||||
async update(groupId, groupData) {
|
||||
return api.put(`/groups/${groupId}`, groupData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除群組
|
||||
*/
|
||||
async delete(groupId) {
|
||||
return api.delete(`/groups/${groupId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得群組關鍵字
|
||||
*/
|
||||
async getKeywords(groupId) {
|
||||
return api.get(`/groups/${groupId}/keywords`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 新增關鍵字
|
||||
*/
|
||||
async addKeyword(groupId, keyword) {
|
||||
return api.post(`/groups/${groupId}/keywords`, { keyword });
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除關鍵字
|
||||
*/
|
||||
async deleteKeyword(groupId, keywordId) {
|
||||
return api.delete(`/groups/${groupId}/keywords/${keywordId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 訂閱管理 API ============
|
||||
const subscriptionsApi = {
|
||||
/**
|
||||
* 取得我的訂閱
|
||||
*/
|
||||
async getMySubscriptions() {
|
||||
return api.get('/subscriptions');
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新訂閱
|
||||
*/
|
||||
async update(subscriptions) {
|
||||
return api.put('/subscriptions', { subscriptions });
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 報告管理 API ============
|
||||
const reportsApi = {
|
||||
/**
|
||||
* 取得報告列表
|
||||
*/
|
||||
async getList(params = {}) {
|
||||
return api.get('/reports', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得今日報告
|
||||
*/
|
||||
async getToday() {
|
||||
return api.get('/reports/today');
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得報告詳情
|
||||
*/
|
||||
async getById(reportId) {
|
||||
return api.get(`/reports/${reportId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新報告
|
||||
*/
|
||||
async update(reportId, reportData) {
|
||||
return api.put(`/reports/${reportId}`, reportData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 發布報告
|
||||
*/
|
||||
async publish(reportId) {
|
||||
return api.post(`/reports/${reportId}/publish`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 重新產生 AI 摘要
|
||||
*/
|
||||
async regenerateSummary(reportId) {
|
||||
return api.post(`/reports/${reportId}/regenerate-summary`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 匯出報告 PDF
|
||||
*/
|
||||
async exportPdf(reportId) {
|
||||
const token = api.getToken();
|
||||
const response = await fetch(`${API_BASE_URL}/reports/${reportId}/export`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('匯出失敗');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 系統設定 API ============
|
||||
const settingsApi = {
|
||||
/**
|
||||
* 取得系統設定
|
||||
*/
|
||||
async get() {
|
||||
return api.get('/settings');
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新系統設定
|
||||
*/
|
||||
async update(settings) {
|
||||
return api.put('/settings', settings);
|
||||
},
|
||||
|
||||
/**
|
||||
* 測試 LLM 連線
|
||||
*/
|
||||
async testLlm() {
|
||||
return api.post('/settings/llm/test');
|
||||
},
|
||||
|
||||
/**
|
||||
* 上傳 PDF Logo
|
||||
*/
|
||||
async uploadLogo(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
return api.upload('/settings/pdf/logo', formData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得管理員儀表板數據
|
||||
*/
|
||||
async getAdminDashboard() {
|
||||
return api.get('/settings/dashboard/admin');
|
||||
}
|
||||
};
|
||||
|
||||
// 匯出所有 API 到 window
|
||||
window.api = api;
|
||||
window.authApi = authApi;
|
||||
window.usersApi = usersApi;
|
||||
window.groupsApi = groupsApi;
|
||||
window.subscriptionsApi = subscriptionsApi;
|
||||
window.reportsApi = reportsApi;
|
||||
window.settingsApi = settingsApi;
|
||||
1010
templates/js/app.js
Normal file
1010
templates/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user