crawler_81tv/static/index.html
2025-06-08 16:25:53 +08:00

1007 lines
34 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>
<head>
<title>定时任务管理</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Bootstrap CSS -->
<link href="/static/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css"
/>
<!-- 自定义样式 -->
<style>
/* 开关按钮样式 */
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.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: 0.4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #2196f3;
}
input:checked + .slider:before {
transform: translateX(26px);
}
/* 状态徽章样式 */
.status-badge {
padding: 0.5em 0.8em;
border-radius: 20px;
font-size: 0.875em;
font-weight: 600;
}
.status-running {
background-color: #0d6efd;
color: white;
}
.status-completed {
background-color: #198754;
color: white;
}
.status-failed {
background-color: #dc3545;
color: white;
}
.status-pending {
background-color: #ffc107;
color: black;
}
/* Toast容器样式 */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1060;
}
/* 表单加载状态样式 */
form.loading {
position: relative;
min-height: 200px;
}
form.loading::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.7);
z-index: 1;
}
/* 编辑模态框样式 */
.modal-body {
max-height: calc(100vh - 210px);
overflow-y: auto;
}
/* 按钮加载状态样式 */
.btn:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.btn .spinner-border {
margin-right: 0.5rem;
vertical-align: middle;
}
.form-control:invalid:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
.form-control.is-valid {
border-color: #198754;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right calc(0.375em + 0.1875rem) center;
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
}
</style>
</head>
<body class="bg-light">
<!-- Toast通知容器 -->
<div class="toast-container"></div>
<div class="container-fluid py-4">
<div class="row g-4">
<!-- 创建定时任务表单 -->
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h2 class="card-title mb-4">创建定时任务</h2>
<form id="taskForm">
<div class="mb-3">
<label for="name" class="form-label">任务名称</label>
<input
type="text"
class="form-control"
id="name"
name="name"
required
/>
</div>
<div class="mb-3">
<label for="cron_expression" class="form-label"
>Cron表达式</label
>
<input
type="text"
class="form-control"
id="cron_expression"
name="cron_expression"
required
/>
</div>
<div class="mb-3">
<label for="spider_name" class="form-label">爬虫名称</label>
<input
type="text"
class="form-control"
id="spider_name"
name="spider_name"
required
/>
</div>
<div class="mb-3">
<label for="url" class="form-label">URL</label>
<input
type="text"
class="form-control"
id="url"
name="url"
required
/>
</div>
<div class="mb-3">
<label for="video_list" class="form-label">剧集ID</label>
<input
type="number"
class="form-control"
id="video_list"
name="video_list"
required
/>
</div>
<button type="submit" class="btn btn-primary">创建任务</button>
</form>
</div>
</div>
</div>
<!-- 定时任务列表 -->
<div class="col-md-8">
<div class="card mb-4">
<div class="card-body">
<h2 class="card-title mb-4">定时任务列表</h2>
<div class="table-responsive">
<table id="taskList" class="table table-striped table-hover">
<thead>
<tr>
<th>任务名称</th>
<th>Cron表达式</th>
<th>爬虫名称</th>
<th>URL</th>
<th>剧集ID</th>
<th>状态</th>
<th>运行状态</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<!-- 爬虫任务列表 -->
<div class="card">
<div class="card-body">
<h2 class="card-title mb-4">爬虫任务列表</h2>
<div class="table-responsive">
<table id="spiderList" class="table table-striped table-hover">
<thead>
<tr>
<th>任务ID</th>
<th>任务名称</th>
<th>爬虫名称</th>
<th>状态</th>
<th>消息</th>
<th>开始时间</th>
<th>结束时间</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- 分页控件 -->
<nav aria-label="Page navigation">
<ul
class="pagination justify-content-center"
id="spiderPagination"
>
<li class="page-item">
<button class="page-link" id="prevPage">上一页</button>
</li>
<li class="page-item">
<span class="page-link" id="pageInfo">1/1</span>
</li>
<li class="page-item">
<button class="page-link" id="nextPage">下一页</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
<!-- 编辑任务模态框 -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑任务</h5>
<button
type="button"
class="btn-close"
onclick="closeEditModal()"
></button>
</div>
<div class="modal-body">
<form id="editTaskForm">
<input type="hidden" id="edit_task_id" name="task_id" />
<div class="mb-3">
<label for="edit_name" class="form-label"
>任务名称 <span class="text-danger">*</span></label
>
<input
type="text"
class="form-control"
id="edit_name"
name="name"
required
placeholder="请输入任务名称"
/>
</div>
<div class="mb-3">
<label for="edit_cron_expression" class="form-label">
Cron表达式 <span class="text-danger">*</span>
<i
class="bi bi-info-circle"
data-bs-toggle="tooltip"
title="Cron表达式格式分 时 日 月 周例如0 2 * * * 表示每天凌晨2点执行"
></i>
</label>
<input
type="text"
class="form-control"
id="edit_cron_expression"
name="cron_expression"
required
placeholder="例如0 2 * * *"
/>
<div class="form-text">使用标准cron表达式格式</div>
</div>
<div class="mb-3">
<label for="edit_spider_name" class="form-label">
爬虫名称 <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
id="edit_spider_name"
name="spider_name"
required
placeholder="请输入爬虫名称"
/>
<div class="form-text">zgjs</div>
</div>
<div class="mb-3">
<label for="edit_url" class="form-label">
URL <span class="text-danger">*</span>
</label>
<input
type="url"
class="form-control"
id="edit_url"
name="url"
required
placeholder="请输入完整的URL地址"
/>
</div>
<div class="mb-3">
<label for="edit_video_list" class="form-label">
剧集ID <span class="text-danger">*</span>
</label>
<input
type="number"
class="form-control"
id="edit_video_list"
name="video_list"
required
min="0"
placeholder="请输入剧集ID"
/>
</div>
<div class="modal-footer px-0 pb-0">
<button
type="button"
class="btn btn-secondary"
onclick="closeEditModal()"
>
取消
</button>
<button type="submit" class="btn btn-primary">保存修改</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Bootstrap -->
<script src="/static/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
// Toast通知函数
function showToast(message, type = "success") {
const toastContainer = document.querySelector(".toast-container");
const toastHtml = `
<div class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML("beforeend", toastHtml);
const toastElement = toastContainer.lastElementChild;
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 3000,
});
toast.show();
// 监听隐藏事件移除DOM元素
toastElement.addEventListener("hidden.bs.toast", () => {
toastElement.remove();
});
}
// 编辑任务相关函数
function showEditModal() {
const modal = new bootstrap.Modal(document.getElementById("editModal"));
modal.show();
}
function closeEditModal() {
const modal = bootstrap.Modal.getInstance(
document.getElementById("editModal")
);
modal.hide();
}
async function editTask(taskId) {
try {
// 先显示模态框,并显示加载状态
showEditModal();
const form = document.getElementById("editTaskForm");
form.classList.add("loading");
// 禁用所有输入字段
const inputs = form.querySelectorAll("input, button");
inputs.forEach((input) => (input.disabled = true));
// 显示加载提示
const loadingHtml = `
<div class="text-center my-4" id="editFormLoading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<p class="mt-2">正在加载任务数据...</p>
</div>
`;
form.insertAdjacentHTML("afterbegin", loadingHtml);
const response = await fetch(`/api/scheduled-tasks/${taskId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const task = await response.json();
// 移除加载提示
const loadingElement = document.getElementById("editFormLoading");
if (loadingElement) {
loadingElement.remove();
}
// 填充表单
document.getElementById("edit_task_id").value = task.id;
document.getElementById("edit_name").value = task.name;
document.getElementById("edit_cron_expression").value =
task.cron_expression;
document.getElementById("edit_spider_name").value = task.spider_name;
document.getElementById("edit_url").value = task.url;
document.getElementById("edit_video_list").value = task.video_list;
// 启用所有输入字段
inputs.forEach((input) => (input.disabled = false));
form.classList.remove("loading");
} catch (error) {
console.error("Error:", error);
showToast(`获取任务详情失败: ${error.message}`, "danger");
closeEditModal();
}
}
// 添加编辑表单重置处理
document
.getElementById("editModal")
.addEventListener("hidden.bs.modal", function () {
const form = document.getElementById("editTaskForm");
form.reset();
// 移除可能存在的加载提示
const loadingElement = document.getElementById("editFormLoading");
if (loadingElement) {
loadingElement.remove();
}
// 确保所有输入字段都被启用
const inputs = form.querySelectorAll("input, button");
inputs.forEach((input) => (input.disabled = false));
form.classList.remove("loading");
});
// 删除任务
async function deleteTask(taskId) {
if (!confirm("确定要删除这个任务吗?")) {
return;
}
try {
const response = await fetch(`/api/scheduled-tasks/${taskId}`, {
method: "DELETE",
});
if (response.ok) {
showToast("任务已删除");
loadTasks();
} else {
const error = await response.json();
showToast(`删除失败: ${error.detail}`, "danger");
}
} catch (error) {
showToast(`删除失败: ${error.message}`, "danger");
}
}
// 更新任务状态显示
function updateTaskStatus(taskId, status) {
const statusCell = document.getElementById(`task-status-${taskId}`);
if (!statusCell) return;
let badgeClass = "";
let statusText = "";
switch (status) {
case "running":
badgeClass = "status-running";
statusText = "运行中";
break;
case "completed":
badgeClass = "status-completed";
statusText = "已完成";
break;
case "failed":
badgeClass = "status-failed";
statusText = "失败";
break;
case "pending":
badgeClass = "status-pending";
statusText = "等待中";
break;
default:
badgeClass = "";
statusText = "未运行";
}
statusCell.innerHTML = `<span class="status-badge ${badgeClass}">${statusText}</span>`;
}
// 加载定时任务列表
async function loadTasks() {
try {
const response = await fetch("/api/scheduled-tasks");
const tasks = await response.json();
const tbody = document.querySelector("#taskList tbody");
tbody.innerHTML = "";
tasks.forEach((task) => {
const row = tbody.insertRow();
row.innerHTML = `
<td>${task.name}</td>
<td>${task.cron_expression}</td>
<td>${task.spider_name}</td>
<td>${task.url}</td>
<td>${task.video_list}</td>
<td>
<label class="switch">
<input type="checkbox" ${task.enabled ? "checked" : ""}
onchange="toggleTask(${task.id}, this.checked)">
<span class="slider"></span>
</label>
</td>
<td id="task-status-${task.id}">
<span class="status-badge">未运行</span>
</td>
<td>
<button class="btn btn-primary btn-sm" onclick="runTask(${
task.id
})">
立即执行
</button>
<button class="btn btn-warning btn-sm" onclick="editTask(${
task.id
})">
编辑
</button>
<button class="btn btn-danger btn-sm" onclick="deleteTask(${
task.id
})">
删除
</button>
</td>
`;
// 检查任务状态
checkTaskStatus(task.id);
});
} catch (error) {
showToast(`加载任务列表失败: ${error.message}`, "danger");
}
}
// 爬虫任务分页状态
let spiderPagination = {
currentPage: 1,
pageSize: 10,
};
// 加载爬虫任务列表(带分页)
async function loadSpiders(page = 1, pageSize = 10) {
try {
const response = await fetch(
`/api/spiders/list?page=${page}&page_size=${pageSize}`
);
const result = await response.json();
// 更新分页状态
spiderPagination = {
currentPage: result.page,
pageSize: result.page_size,
total: result.total,
totalPages: result.total_pages,
};
// 更新表格内容
const tbody = document.querySelector("#spiderList tbody");
tbody.innerHTML = "";
result.items.forEach((spider) => {
const row = tbody.insertRow();
row.innerHTML = `
<td>${spider.task_id}</td>
<td>${spider.task_name}</td>
<td>${spider.spider_name}</td>
<td><span class="status-badge ${getStatusBadgeClass(
spider.status
)}">${getStatusText(spider.status)}</span></td>
<td>${spider.message}</td>
<td>${spider.start_time || "-"}</td>
<td>${spider.end_time || "-"}</td>
`;
});
// 更新分页信息
updateSpiderPaginationUI();
} catch (error) {
showToast(`加载爬虫任务列表失败: ${error.message}`, "danger");
}
}
// 更新分页UI
function updateSpiderPaginationUI() {
const pageInfo = document.getElementById("pageInfo");
const prevBtn = document.getElementById("prevPage");
const nextBtn = document.getElementById("nextPage");
pageInfo.textContent = `${spiderPagination.currentPage}/${spiderPagination.totalPages}`;
// 更新按钮状态
prevBtn.disabled = spiderPagination.currentPage <= 1;
nextBtn.disabled =
spiderPagination.currentPage >= spiderPagination.totalPages;
}
// 分页按钮事件
document.getElementById("prevPage").addEventListener("click", () => {
if (spiderPagination.currentPage > 1) {
loadSpiders(
spiderPagination.currentPage - 1,
spiderPagination.pageSize
);
}
});
document.getElementById("nextPage").addEventListener("click", () => {
if (spiderPagination.currentPage < spiderPagination.totalPages) {
loadSpiders(
spiderPagination.currentPage + 1,
spiderPagination.pageSize
);
}
});
function getStatusBadgeClass(status) {
switch (status) {
case "running":
return "status-running";
case "completed":
return "status-completed";
case "failed":
return "status-failed";
case "pending":
return "status-pending";
default:
return "";
}
}
function getStatusText(status) {
switch (status) {
case "running":
return "运行中";
case "completed":
return "已完成";
case "failed":
return "失败";
case "pending":
return "等待中";
default:
return "未知";
}
}
// 保存编辑的任务
document
.getElementById("editTaskForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const taskId = document.getElementById("edit_task_id").value;
const formData = {
name: document.getElementById("edit_name").value.trim(),
cron_expression: document
.getElementById("edit_cron_expression")
.value.trim(),
spider_name: document
.getElementById("edit_spider_name")
.value.trim(),
url: document.getElementById("edit_url").value.trim(),
video_list: parseInt(
document.getElementById("edit_video_list").value
),
enabled: true, // 保持任务启用状态
};
// 验证必填字段
const validationErrors = [];
if (!formData.name) validationErrors.push("任务名称不能为空");
if (!formData.cron_expression)
validationErrors.push("Cron表达式不能为空");
if (!formData.spider_name) validationErrors.push("爬虫名称不能为空");
if (!formData.url) validationErrors.push("URL不能为空");
if (isNaN(formData.video_list) || formData.video_list < 0)
validationErrors.push("剧集ID必须是非负数字");
if (validationErrors.length > 0) {
showToast(validationErrors.join("<br>"), "danger");
return;
}
// 显示加载状态
const submitButton = e.target.querySelector('button[type="submit"]');
const originalText = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 保存中...';
try {
const response = await fetch(`/api/scheduled-tasks/${taskId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const data = await response.json();
if (response.ok) {
showToast("任务更新成功", "success");
closeEditModal();
await loadTasks(); // 等待任务列表刷新完成
} else {
showToast(`更新失败: ${data.detail || "未知错误"}`, "danger");
}
} catch (error) {
console.error("Error:", error);
showToast(`更新失败: ${error.message || "网络错误"}`, "danger");
} finally {
// 恢复按钮状态
submitButton.disabled = false;
submitButton.innerHTML = originalText;
}
});
// 定期检查任务状态
async function checkTaskStatus(taskId) {
try {
const response = await fetch(`/api/task-status/${taskId}`);
if (response.ok) {
const status = await response.json();
updateTaskStatus(taskId, status.status);
// 如果任务仍在运行,继续检查
if (status.status === "running" || status.status === "pending") {
setTimeout(() => checkTaskStatus(taskId), 5000); // 每5秒检查一次
}
}
} catch (error) {
console.error("检查任务状态失败:", error);
}
}
// 修改runTask函数以支持状态更新
async function runTask(taskId) {
try {
const response = await fetch(`/api/scheduled-tasks/${taskId}/run`, {
method: "POST",
});
if (response.status === 200) {
// const result = await response.json();
showToast("任务已开始运行");
// 开始检查任务状态
checkTaskStatus(taskId);
// 刷新爬虫任务列表
loadSpiders();
} else {
const error = await response.json();
showToast(`运行失败: ${error.detail}`, "danger");
}
} catch (error) {
showToast(`运行失败: ${error.message}`, "danger");
}
}
async function toggleTask(taskId, enabled) {
try {
const response = await fetch(
`/api/scheduled-tasks/${taskId}/toggle`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
}
);
if (response.ok) {
showToast(`任务已${enabled ? "启用" : "禁用"}`);
} else {
const error = await response.json();
showToast(`切换状态失败: ${error.detail}`, "danger");
// 恢复复选框状态
loadTasks();
}
} catch (error) {
showToast(`切换状态失败: ${error.message}`, "danger");
// 恢复复选框状态
loadTasks();
}
}
document
.getElementById("taskForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = {
name: document.getElementById("name").value,
cron_expression: document.getElementById("cron_expression").value,
spider_name: document.getElementById("spider_name").value,
url: document.getElementById("url").value,
video_list: parseInt(document.getElementById("video_list").value),
};
try {
const response = await fetch("/api/scheduled-tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (response.ok) {
showToast("任务已创建");
e.target.reset();
loadTasks();
} else {
const error = await response.json();
showToast(`创建失败: ${error.detail}`, "danger");
}
} catch (error) {
showToast(`创建失败: ${error.message}`, "danger");
}
});
// 添加表单验证函数
function validateFormField(input) {
const field = input.id.replace("edit_", "");
const value = input.value.trim();
let isValid = true;
let errorMessage = "";
switch (field) {
case "name":
if (!value) {
isValid = false;
errorMessage = "任务名称不能为空";
}
break;
case "cron_expression":
if (!value) {
isValid = false;
errorMessage = "Cron表达式不能为空";
} else if (!/^(\S+\s+){4}\S+$/.test(value)) {
isValid = false;
errorMessage = "Cron表达式格式无效";
}
break;
case "spider_name":
if (!value) {
isValid = false;
errorMessage = "爬虫名称不能为空";
}
break;
case "url":
if (!value) {
isValid = false;
errorMessage = "URL不能为空";
} else {
try {
new URL(value);
} catch {
isValid = false;
errorMessage = "URL格式无效";
}
}
break;
case "video_list":
if (!value || isNaN(value) || parseInt(value) < 0) {
isValid = false;
errorMessage = "剧集ID必须是非负数字";
}
break;
}
// 更新输入框状态
input.classList.remove("is-valid", "is-invalid");
input.classList.add(isValid ? "is-valid" : "is-invalid");
// 更新或创建反馈提示
let feedback = input.nextElementSibling;
if (!feedback || !feedback.classList.contains("invalid-feedback")) {
feedback = document.createElement("div");
feedback.className = "invalid-feedback";
input.parentNode.insertBefore(feedback, input.nextSibling);
}
feedback.textContent = errorMessage;
return isValid;
}
// 为编辑表单的所有输入字段添加验证
document.querySelectorAll("#editTaskForm input").forEach((input) => {
input.addEventListener("input", () => validateFormField(input));
input.addEventListener("blur", () => validateFormField(input));
});
// 修改编辑表单提交验证
document
.getElementById("editTaskForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
// 验证所有字段
const inputs = e.target.querySelectorAll(
'input:not([type="hidden"])'
);
let isValid = true;
inputs.forEach((input) => {
if (!validateFormField(input)) {
isValid = false;
}
});
if (!isValid) {
showToast("请修正表单中的错误", "danger");
return;
}
});
// 初始化所有工具提示
function initTooltips() {
const tooltipTriggerList = [].slice.call(
document.querySelectorAll('[data-bs-toggle="tooltip"]')
);
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
}
// 在模态框显示时初始化工具提示
document
.getElementById("editModal")
.addEventListener("shown.bs.modal", function () {
initTooltips();
});
// 页面加载时获取任务列表
loadTasks();
loadSpiders();
// 初始化页面上的工具提示
initTooltips();
// 定期刷新爬虫任务列表
setInterval(loadSpiders, 5000); // 每5秒刷新一次
</script>
</body>
</html>