Files
ewoooc/templates/settings.html
OoO 53edcc0077 refactor(templates): 統一模板目錄並移除 fallback loader
ADR-017 Phase 3f-4:根目錄模板搬入 templates/,補 trends/login_history,移除 ChoiceLoader 根目錄 fallback,搬移 components,刪除 web/templates 下的空檔/死檔與 compose 舊模板 mount。
2026-04-29 21:44:38 +08:00

1651 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>爬蟲管理 - WOOO TECH</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
min-height: 100vh;
padding-top: 70px;
}
.navbar-dark.bg-primary {
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.navbar-dark .navbar-brand {
color: #ffffff !important;
font-weight: 600;
}
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
transition: all 0.3s;
}
.navbar-dark .navbar-nav .nav-link:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.navbar-dark .navbar-nav .nav-link.active {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-weight: 600;
}
.navbar-dark .navbar-text {
color: rgba(255, 255, 255, 0.8) !important;
}
.navbar {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%) !important;
}
/* Page Header */
.page-header {
background: white;
padding: 25px 30px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h4 {
margin: 0;
color: #1f2937;
font-weight: 600;
}
.refresh-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(102, 126, 234, 0.25);
}
.refresh-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(102, 126, 234, 0.35);
}
.refresh-btn:active {
transform: translateY(0);
}
.refresh-btn.spinning i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Statistics Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 35px;
}
.stat-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
}
.stat-card.active::before {
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
}
.stat-card.paused::before {
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0,0,0,0.12);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 15px;
}
.stat-card.active .stat-icon {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
color: #059669;
}
.stat-card.paused .stat-icon {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #d97706;
}
.stat-card.total .stat-icon {
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
color: #6366f1;
}
.stat-number {
font-size: 36px;
font-weight: 700;
color: #1f2937;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
color: #6b7280;
font-size: 14px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Crawler Cards */
.crawler-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 25px;
margin-bottom: 20px;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.crawler-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: #d1d5db;
transition: all 0.3s;
}
.crawler-card.active::before {
background: linear-gradient(180deg, #10b981 0%, #059669 100%);
}
.crawler-card.paused::before {
background: linear-gradient(180deg, #f59e0b 0%, #d97706 100%);
}
.crawler-card:hover {
box-shadow: 0 8px 16px rgba(0,0,0,0.12);
transform: translateX(4px);
}
.crawler-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-left: 15px;
}
.crawler-title-group {
flex: 1;
}
.crawler-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 10px;
}
.crawler-title i {
font-size: 22px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.status-badge.active {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
color: #065f46;
}
.status-badge.paused {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #92400e;
}
.status-badge i {
font-size: 10px;
}
.crawler-body {
padding-left: 15px;
padding-top: 70px;
}
.crawler-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.info-item {
display: flex;
align-items: center;
gap: 10px;
color: #4b5563;
font-size: 14px;
padding: 10px;
background: #f9fafb;
border-radius: 8px;
}
.info-item i {
color: #6b7280;
font-size: 16px;
width: 20px;
text-align: center;
}
.pause-reason {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-left: 4px solid #f59e0b;
padding: 15px;
margin: 15px 0;
border-radius: 8px;
}
.pause-reason strong {
color: #92400e;
display: block;
margin-bottom: 5px;
}
.pause-reason small {
color: #78350f;
font-size: 13px;
}
.crawler-controls {
display: flex;
gap: 12px;
padding-top: 20px;
border-top: 2px solid #f3f4f6;
align-items: center;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 56px;
height: 30px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #d1d5db 0%, #9ca3af 100%);
transition: .4s;
border-radius: 30px;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.slider:before {
position: absolute;
content: "";
height: 24px;
width: 24px;
left: 3px;
bottom: 3px;
background: white;
transition: .4s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
input:checked + .slider {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
input:checked + .slider:before {
transform: translateX(26px);
}
/* Buttons */
.btn-custom {
padding: 10px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-schedule {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3);
}
.btn-schedule:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
/* Info Section */
.info-section {
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
border-left: 4px solid #8b5cf6;
padding: 20px;
border-radius: 12px;
margin: 30px 0;
}
.info-section h5 {
color: #5b21b6;
font-weight: 600;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.info-section ul {
margin: 0;
padding-left: 20px;
color: #6b21a8;
}
.info-section li {
margin: 10px 0;
line-height: 1.6;
}
.info-section code {
background: rgba(139, 92, 246, 0.1);
padding: 3px 8px;
border-radius: 4px;
color: #6b21a8;
font-size: 13px;
}
/* Section Divider */
.section-divider {
margin: 50px 0;
border: 0;
height: 2px;
background: linear-gradient(90deg, transparent 0%, #d1d5db 50%, transparent 100%);
}
/* Category Section */
.section-header {
background: white;
padding: 20px 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin-bottom: 25px;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h5 {
margin: 0;
color: #1f2937;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
/* Search Box */
.category-search-container {
background: white;
padding: 20px 25px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin-bottom: 25px;
}
.search-box {
position: relative;
}
.search-box input {
width: 100%;
padding: 12px 45px 12px 45px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 15px;
transition: all 0.3s;
}
.search-box input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.search-box .search-icon {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
font-size: 16px;
}
.search-box .clear-icon {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
font-size: 16px;
cursor: pointer;
display: none;
transition: color 0.3s;
}
.search-box .clear-icon:hover {
color: #6b7280;
}
.search-box.has-text .clear-icon {
display: block;
}
/* Category Cards Grid */
.categories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.category-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 25px;
transition: all 0.3s;
position: relative;
overflow: hidden;
border: 2px solid transparent;
}
.category-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%);
}
.category-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.12);
border-color: #e0e7ff;
}
.category-card-header {
margin-bottom: 15px;
}
.category-name {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.category-name i {
color: #6366f1;
font-size: 22px;
}
.category-url {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
margin-bottom: 20px;
word-break: break-all;
}
.category-url i {
color: #6b7280;
font-size: 14px;
flex-shrink: 0;
}
.category-url a {
color: #4b5563;
text-decoration: none;
font-size: 13px;
transition: color 0.3s;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.category-url a:hover {
color: #6366f1;
}
.category-actions {
display: flex;
gap: 10px;
padding-top: 15px;
border-top: 2px solid #f3f4f6;
}
.category-btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.category-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.category-btn:active {
transform: translateY(0);
}
.category-btn.test {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.3);
}
.category-btn.test:hover {
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.category-btn.edit {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.3);
}
.category-btn.edit:hover {
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.category-btn.delete {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
box-shadow: 0 2px 6px rgba(239, 68, 68, 0.3);
}
.category-btn.delete:hover {
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.category-btn i {
font-size: 15px;
}
.category-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
/* Empty State */
.empty-state {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 60px 40px;
text-align: center;
}
.empty-state-icon {
width: 120px;
height: 120px;
margin: 0 auto 25px;
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-icon i {
font-size: 60px;
color: #6366f1;
opacity: 0.7;
}
.empty-state h5 {
color: #1f2937;
font-weight: 600;
font-size: 22px;
margin-bottom: 12px;
}
.empty-state p {
color: #6b7280;
font-size: 15px;
margin-bottom: 30px;
line-height: 1.6;
}
.empty-state-btn {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
border: none;
padding: 14px 32px;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 10px;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.empty-state-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
}
.empty-state-btn i {
font-size: 18px;
}
/* No Results State */
.no-results {
text-align: center;
padding: 40px 20px;
color: #6b7280;
}
.no-results i {
font-size: 48px;
color: #d1d5db;
margin-bottom: 15px;
display: block;
}
.no-results p {
font-size: 16px;
margin: 0;
}
.table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 25px;
overflow: hidden;
}
.table th {
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
padding: 15px 12px;
}
.table td {
vertical-align: middle;
padding: 15px 12px;
}
.url-cell {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.url-cell a {
color: #6366f1;
text-decoration: none;
transition: color 0.3s;
}
.url-cell a:hover {
color: #4f46e5;
text-decoration: underline;
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
}
.toast-notification {
background: white;
padding: 16px 20px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
margin-bottom: 12px;
min-width: 300px;
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast-notification.success {
border-left: 4px solid #10b981;
}
.toast-notification.success .toast-icon {
color: #10b981;
}
.toast-notification.error {
border-left: 4px solid #ef4444;
}
.toast-notification.error .toast-icon {
color: #ef4444;
}
.toast-icon {
font-size: 20px;
}
.toast-content {
flex: 1;
}
.toast-message {
color: #1f2937;
font-size: 14px;
font-weight: 500;
}
/* Modal Enhancements */
.modal-content {
border-radius: 12px;
border: none;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.modal-header {
border-bottom: 2px solid #f3f4f6;
padding: 20px 25px;
}
.modal-title {
font-weight: 600;
color: #1f2937;
}
.modal-body {
padding: 25px;
padding-top: 70px;
}
.modal-footer {
border-top: 2px solid #f3f4f6;
padding: 20px 25px;
}
.form-label {
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.form-control {
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
transition: all 0.3s;
}
.form-control:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9998;
}
.loading-overlay.active {
display: flex;
}
.loading-spinner {
background: white;
padding: 30px;
border-radius: 12px;
text-align: center;
}
.loading-spinner i {
font-size: 40px;
color: #6366f1;
animation: spin 1s linear infinite;
}
.loading-text {
margin-top: 15px;
color: #4b5563;
font-weight: 500;
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 15px;
}
.refresh-btn {
width: 100%;
}
.stats-grid {
grid-template-columns: 1fr;
}
.crawler-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.section-header {
flex-direction: column;
gap: 15px;
align-items: stretch;
}
.categories-grid {
grid-template-columns: 1fr;
}
.category-actions {
flex-direction: column;
}
.category-btn {
width: 100%;
}
.empty-state {
padding: 40px 20px;
}
.empty-state-icon {
width: 90px;
height: 90px;
}
.empty-state-icon i {
font-size: 45px;
}
}
/* Custom Dark Gray Navbar */
.navbar.bg-custom-dark {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.navbar.bg-custom-dark .navbar-brand {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
color: #ffffff;
}
.navbar.bg-custom-dark .navbar-nav .nav-link.active {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-text {
color: rgba(255, 255, 255, 0.75);
}
</style>
</head>
<body>
{% include 'components/_navbar.html' %}
<div class="container">
<!-- Page Header -->
<div class="page-header">
<h4><i class="fas fa-robot me-2"></i>爬蟲管理中心</h4>
<button class="refresh-btn" onclick="refreshData()">
<i class="fas fa-sync-alt me-2"></i>刷新狀態
</button>
</div>
<!-- Statistics -->
<div class="stats-grid">
<div class="stat-card active">
<div class="stat-icon">
<i class="fas fa-play-circle"></i>
</div>
<div class="stat-number" id="enabled-count">0</div>
<div class="stat-label">啟用中</div>
</div>
<div class="stat-card paused">
<div class="stat-icon">
<i class="fas fa-pause-circle"></i>
</div>
<div class="stat-number" id="paused-count">0</div>
<div class="stat-label">已暫停</div>
</div>
<div class="stat-card total">
<div class="stat-icon">
<i class="fas fa-layer-group"></i>
</div>
<div class="stat-number" id="total-count">0</div>
<div class="stat-label">爬蟲總數</div>
</div>
</div>
<!-- Crawlers Container -->
<div id="crawlers-container">
<!-- 爬蟲卡片將通過 JavaScript 動態插入 -->
</div>
<!-- Usage Info -->
<div class="info-section">
<h5><i class="fas fa-info-circle"></i>使用說明</h5>
<ul>
<li>使用右側切換開關可以快速啟用或停用爬蟲</li>
<li>停用的爬蟲程式碼和資料會完整保留,可隨時重新啟用</li>
<li>點擊「修改頻率」按鈕可調整爬蟲的執行間隔時間</li>
<li>變更執行頻率後,需要重啟排程器服務才會生效:<code>sudo systemctl restart momo-scheduler</code></li>
<li>點擊右上角「刷新狀態」按鈕可即時更新所有爬蟲狀態</li>
</ul>
</div>
<!-- Section Divider -->
<hr class="section-divider">
<!-- Category Settings Section -->
<div class="section-header">
<h5><i class="fas fa-list-alt me-2"></i>爬蟲網址設定</h5>
<button class="btn btn-primary" onclick="prepareAddModal()">
<i class="fas fa-plus me-2"></i>新增分類
</button>
</div>
<!-- Search Box -->
<div class="category-search-container" id="search-container" style="display: none;">
<div class="search-box" id="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="category-search" placeholder="搜尋分類名稱或 URL..." oninput="filterCategories()">
<i class="fas fa-times clear-icon" onclick="clearSearch()"></i>
</div>
</div>
<!-- Categories Grid -->
<div id="categories-container">
{% if categories %}
<div class="categories-grid" id="categories-grid">
{% for category in categories %}
<div class="category-card" data-category-name="{{ category.name|lower }}" data-category-url="{{ category.url|lower }}">
<div class="category-card-header">
<div class="category-name">
<i class="fas fa-tag"></i>
{{ category.name }}
</div>
</div>
<div class="category-url">
<i class="fas fa-link"></i>
<a href="{{ category.url }}" target="_blank" title="{{ category.url }}">{{ category.url }}</a>
</div>
<div class="category-actions">
<button class="category-btn test"
data-url="{{ category.url }}"
onclick="testUrl(this.dataset.url, this)"
title="測試連結有效性">
<i class="fas fa-stethoscope"></i>
<span>測試</span>
</button>
<button class="category-btn edit"
data-id="{{ category.id }}"
data-name="{{ category.name }}"
data-url="{{ category.url }}"
onclick="prepareEditModal(this.dataset.id, this.dataset.name, this.dataset.url)"
title="編輯分類">
<i class="fas fa-edit"></i>
<span>編輯</span>
</button>
<button class="category-btn delete"
data-id="{{ category.id }}"
onclick="deleteCategory(this.dataset.id)"
title="刪除分類">
<i class="fas fa-trash"></i>
<span>刪除</span>
</button>
</div>
</div>
{% endfor %}
</div>
<div class="no-results" id="no-results" style="display: none;">
<i class="fas fa-search"></i>
<p>找不到符合的分類</p>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">
<i class="fas fa-folder-open"></i>
</div>
<h5>尚未建立任何分類</h5>
<p>開始建立您的第一個爬蟲分類,追蹤 MOMO 商城的商品變化</p>
<button class="empty-state-btn" onclick="prepareAddModal()">
<i class="fas fa-plus-circle"></i>
立即新增分類
</button>
</div>
{% endif %}
</div>
<div class="alert alert-info mb-5">
<i class="fas fa-lightbulb me-2"></i>
<strong>提示:</strong> 分類設定採用動態讀取機制,新增或修改後<strong>無需重啟系統</strong>,下一次爬蟲任務執行時將自動載入最新設定。
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loading-overlay">
<div class="loading-spinner">
<i class="fas fa-spinner"></i>
<div class="loading-text">處理中,請稍候...</div>
</div>
</div>
<!-- Category Modal -->
<div class="modal fade" id="categoryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">新增分類</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="categoryForm" onsubmit="saveCategory(); return false;">
<input type="hidden" id="categoryId" name="id">
<div class="mb-3">
<label for="categoryName" class="form-label">
<i class="fas fa-tag me-2"></i>分類名稱
</label>
<input type="text" class="form-control" id="categoryName" name="name" required placeholder="例如3C家電">
</div>
<div class="mb-3">
<label for="categoryUrl" class="form-label">
<i class="fas fa-link me-2"></i>分類 URL
</label>
<input type="url" class="form-control" id="categoryUrl" name="url" required placeholder="https://www.momoshop.com.tw/...">
<div class="form-text">請輸入完整的 MOMO 商城分類網址</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-2"></i>取消
</button>
<button type="submit" form="categoryForm" class="btn btn-primary">
<i class="fas fa-save me-2"></i>儲存
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
let categoryModal;
document.addEventListener('DOMContentLoaded', function() {
categoryModal = new bootstrap.Modal(document.getElementById('categoryModal'));
loadCrawlers();
});
// ===== Utility Functions =====
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast-notification ${type}`;
const icon = type === 'success' ? 'check-circle' : 'exclamation-circle';
toast.innerHTML = `
<div class="toast-icon">
<i class="fas fa-${icon}"></i>
</div>
<div class="toast-content">
<div class="toast-message">${message}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function showLoading() {
document.getElementById('loading-overlay').classList.add('active');
}
function hideLoading() {
document.getElementById('loading-overlay').classList.remove('active');
}
function getCSRFToken() {
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
}
// ===== Crawler Management Functions =====
async function loadCrawlers() {
try {
const response = await fetch('/api/crawlers');
const result = await response.json();
if (result.status === 'success') {
renderCrawlers(result.data);
updateStats(result.data);
} else {
showToast('載入失敗: ' + result.message, 'error');
}
} catch (error) {
showToast('載入爬蟲配置時發生錯誤', 'error');
console.error(error);
}
}
function renderCrawlers(crawlers) {
const container = document.getElementById('crawlers-container');
container.innerHTML = '';
for (const [key, info] of Object.entries(crawlers)) {
const card = createCrawlerCard(key, info);
container.appendChild(card);
}
}
function createCrawlerCard(key, info) {
const card = document.createElement('div');
card.className = `crawler-card ${info.enabled ? 'active' : 'paused'}`;
const statusClass = info.enabled ? 'active' : 'paused';
const statusText = info.enabled ? '運行中' : '已暫停';
const statusIcon = info.enabled ? 'fa-circle' : 'fa-circle';
card.innerHTML = `
<div class="crawler-header">
<div class="crawler-title-group">
<div class="crawler-title">
<i class="fas fa-robot"></i>
${info.name}
</div>
<span class="status-badge ${statusClass}">
<i class="fas ${statusIcon}"></i>
${statusText}
</span>
</div>
<label class="toggle-switch">
<input type="checkbox" ${info.enabled ? 'checked' : ''}
onchange="toggleCrawler('${key}', this.checked)">
<span class="slider"></span>
</label>
</div>
<div class="crawler-body">
<div class="crawler-info">
<div class="info-item">
<i class="fas fa-info-circle"></i>
<span>${info.description || 'N/A'}</span>
</div>
<div class="info-item">
<i class="fas fa-clock"></i>
<span>每 ${info.schedule_hours || 'N/A'} 小時執行</span>
</div>
${info.lpn_code ? `
<div class="info-item">
<i class="fas fa-barcode"></i>
<span>活動代碼:${info.lpn_code}</span>
</div>
` : ''}
${info.last_active_date ? `
<div class="info-item">
<i class="fas fa-calendar-check"></i>
<span>最後活動:${info.last_active_date}</span>
</div>
` : ''}
</div>
${!info.enabled && info.pause_reason ? `
<div class="pause-reason">
<strong><i class="fas fa-pause-circle me-2"></i>暫停原因</strong>
${info.pause_reason}
${info.notes ? `<br><small><i class="fas fa-sticky-note me-1"></i>${info.notes}</small>` : ''}
</div>
` : ''}
${info.enabled ? `
<div class="crawler-controls">
<button class="btn-custom btn-schedule" onclick="changeSchedule('${key}', ${info.schedule_hours})">
<i class="fas fa-edit"></i>
修改頻率
</button>
</div>
` : ''}
</div>
`;
return card;
}
function updateStats(crawlers) {
const total = Object.keys(crawlers).length;
const enabled = Object.values(crawlers).filter(c => c.enabled).length;
const paused = total - enabled;
document.getElementById('enabled-count').textContent = enabled;
document.getElementById('paused-count').textContent = paused;
document.getElementById('total-count').textContent = total;
}
async function toggleCrawler(key, enabled) {
let reason = '';
if (!enabled) {
reason = prompt('請輸入停用原因(選填):') || '手動停用';
}
showLoading();
try {
const response = await fetch(`/api/crawlers/${key}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ enabled, reason })
});
const result = await response.json();
if (result.status === 'success') {
showToast(result.message, 'success');
await loadCrawlers();
} else {
showToast('操作失敗: ' + result.message, 'error');
await loadCrawlers();
}
} catch (error) {
showToast('操作時發生錯誤', 'error');
console.error(error);
await loadCrawlers();
} finally {
hideLoading();
}
}
async function changeSchedule(key, currentHours) {
const newHours = prompt(`請輸入新的執行頻率小時範圍1-24\n\n目前設定:每 ${currentHours} 小時`, currentHours);
if (newHours === null || newHours === currentHours.toString()) {
return;
}
const hours = parseInt(newHours);
if (isNaN(hours) || hours < 1 || hours > 24) {
showToast('請輸入有效的小時數1-24', 'error');
return;
}
showLoading();
try {
const response = await fetch(`/api/crawlers/${key}/schedule`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ schedule_hours: hours })
});
const result = await response.json();
if (result.status === 'success') {
showToast(result.message + '(需重啟排程器生效)', 'success');
await loadCrawlers();
} else {
showToast('更新失敗: ' + result.message, 'error');
}
} catch (error) {
showToast('更新時發生錯誤', 'error');
console.error(error);
} finally {
hideLoading();
}
}
async function refreshData() {
const btn = event.target.closest('.refresh-btn');
btn.classList.add('spinning');
btn.disabled = true;
await loadCrawlers();
setTimeout(() => {
btn.classList.remove('spinning');
btn.disabled = false;
showToast('已刷新爬蟲狀態', 'success');
}, 600);
}
// ===== Category Management Functions =====
function prepareAddModal() {
document.getElementById('modalTitle').innerText = '新增分類';
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryUrl').value = '';
categoryModal.show();
}
function prepareEditModal(id, name, url) {
document.getElementById('modalTitle').innerText = '編輯分類';
document.getElementById('categoryId').value = id;
document.getElementById('categoryName').value = name;
document.getElementById('categoryUrl').value = url;
categoryModal.show();
}
function saveCategory() {
const id = document.getElementById('categoryId').value;
const name = document.getElementById('categoryName').value;
const url = document.getElementById('categoryUrl').value;
const method = id ? 'PUT' : 'POST';
const endpoint = id ? `/api/categories/${id}` : '/api/categories';
const formData = new FormData();
formData.append('name', name);
formData.append('url', url);
showLoading();
fetch(endpoint, {
method: method,
headers: {
'X-CSRFToken': getCSRFToken()
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
categoryModal.hide();
showToast(id ? '分類已更新' : '分類已新增', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast('操作失敗: ' + data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showToast('發生錯誤,請稍後再試', 'error');
})
.finally(() => {
hideLoading();
});
}
function deleteCategory(id) {
if (confirm('確定要刪除此分類嗎?此操作無法復原。')) {
showLoading();
fetch(`/api/categories/${id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showToast('分類已刪除', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast('刪除失敗: ' + data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showToast('發生錯誤,請稍後再試', 'error');
})
.finally(() => {
hideLoading();
});
}
}
function testUrl(url, btn) {
const originalHtml = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
btn.disabled = true;
fetch('/api/test_url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({url: url})
})
.then(response => response.json())
.then(data => {
showToast(data.message, data.status === 'success' ? 'success' : 'error');
})
.catch(error => {
console.error('Error:', error);
showToast('測試請求失敗', 'error');
})
.finally(() => {
btn.innerHTML = originalHtml;
btn.disabled = false;
});
}
// ===== Search and Filter Functions =====
// Show search box when page loads if there are categories
document.addEventListener('DOMContentLoaded', function() {
const categoriesGrid = document.getElementById('categories-grid');
const searchContainer = document.getElementById('search-container');
if (categoriesGrid && categoriesGrid.children.length > 0) {
searchContainer.style.display = 'block';
}
});
function filterCategories() {
const searchInput = document.getElementById('category-search');
const searchBox = document.getElementById('search-box');
const searchTerm = searchInput.value.toLowerCase().trim();
const cards = document.querySelectorAll('.category-card');
const noResults = document.getElementById('no-results');
let visibleCount = 0;
// Toggle clear icon visibility
if (searchTerm) {
searchBox.classList.add('has-text');
} else {
searchBox.classList.remove('has-text');
}
// Filter cards
cards.forEach(card => {
const categoryName = card.dataset.categoryName;
const categoryUrl = card.dataset.categoryUrl;
if (categoryName.includes(searchTerm) || categoryUrl.includes(searchTerm)) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0 && searchTerm) {
noResults.style.display = 'block';
} else {
noResults.style.display = 'none';
}
}
function clearSearch() {
const searchInput = document.getElementById('category-search');
const searchBox = document.getElementById('search-box');
searchInput.value = '';
searchBox.classList.remove('has-text');
filterCategories();
searchInput.focus();
}
</script>
</body>
</html>