ADR-017 Phase 3f-4:根目錄模板搬入 templates/,補 trends/login_history,移除 ChoiceLoader 根目錄 fallback,搬移 components,刪除 web/templates 下的空檔/死檔與 compose 舊模板 mount。
1651 lines
50 KiB
HTML
1651 lines
50 KiB
HTML
<!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>
|