Files
tool-node/public/index.html
zhaojunlong d78dc655ee ```
chore(config): 更新.gitignore文件以忽略数据库相关文件

添加了data/目录、SQLite数据库文件及相关临时文件到.gitignore中,
避免敏感数据和临时文件被提交到版本控制系统。
```
2026-03-19 10:18:25 +08:00

1225 lines
47 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-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>公告抓取与分析工具</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;600;700&family=Fira+Code:wght@500;600&family=Noto+Sans+SC:wght@400;500;700&display=swap');
:root {
--primary: #0f6ecd;
--primary-hover: #0c5caf;
--secondary: #0ea5a4;
--accent: #f59e0b;
--success: #0f9d58;
--danger: #d94848;
--text: #10233a;
--muted: #4f647d;
--line: rgba(15, 35, 58, 0.14);
--surface: rgba(255, 255, 255, 0.64);
--surface-strong: rgba(255, 255, 255, 0.82);
--shadow: 0 24px 50px rgba(15, 42, 74, 0.18);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Fira Sans', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: var(--text);
min-height: 100vh;
padding: 20px;
background:
radial-gradient(1200px 600px at -10% -10%, rgba(15, 110, 205, 0.20), transparent 60%),
radial-gradient(900px 500px at 110% -20%, rgba(14, 165, 164, 0.20), transparent 55%),
linear-gradient(145deg, #edf6ff 0%, #e8fbf6 48%, #f7fbff 100%);
}
.container {
max-width: 1600px;
margin: 0 auto;
border-radius: 20px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--surface);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.header {
padding: 26px 30px;
text-align: center;
color: #f6fbff;
background: linear-gradient(120deg, #0f6ecd 0%, #0e9ab2 68%, #10b981 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.28);
}
.header h1 {
font-family: 'Fira Code', 'Noto Sans SC', sans-serif;
letter-spacing: 0.2px;
font-size: 24px;
margin-bottom: 6px;
}
.header p {
opacity: 0.92;
font-size: 13px;
}
.tabs {
display: flex;
gap: 6px;
padding: 8px;
background: rgba(255, 255, 255, 0.36);
border-bottom: 1px solid var(--line);
}
.tab {
flex: 1;
padding: 12px;
text-align: center;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
border-radius: 10px;
font-size: 15px;
font-weight: 600;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.tab:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.56);
border-color: var(--line);
}
.tab.active {
color: var(--primary);
background: var(--surface-strong);
border-color: rgba(15, 110, 205, 0.30);
box-shadow: inset 0 0 0 1px rgba(15, 110, 205, 0.12);
}
.content {
padding: 24px;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
label {
display: block;
font-weight: 600;
margin-bottom: 6px;
color: var(--text);
font-size: 14px;
}
input[type="text"],
input[type="number"],
input[type="password"],
textarea,
select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
font-size: 14px;
background: rgba(255, 255, 255, 0.76);
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: rgba(15, 110, 205, 0.55);
box-shadow: 0 0 0 3px rgba(15, 110, 205, 0.12);
background: #ffffff;
}
textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.form-group {
margin-bottom: 16px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row>* {
flex: 1;
}
.btn {
padding: 10px 18px;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-primary {
color: #fff;
background: linear-gradient(120deg, var(--primary), #178ac2);
box-shadow: 0 8px 18px rgba(15, 110, 205, 0.25);
}
.btn-primary:hover {
background: linear-gradient(120deg, var(--primary-hover), #1279aa);
}
.btn-success {
color: #fff;
background: linear-gradient(120deg, #0f9d58, #14b46c);
box-shadow: 0 8px 16px rgba(15, 157, 88, 0.24);
}
.btn-success:hover {
filter: brightness(0.96);
}
.btn-danger {
color: #fff;
background: linear-gradient(120deg, #d94848, #e26a5f);
box-shadow: 0 8px 16px rgba(217, 72, 72, 0.22);
}
.btn-danger:hover {
filter: brightness(0.96);
}
.btn-secondary {
color: #1e3856;
background: rgba(255, 255, 255, 0.72);
border-color: var(--line);
}
.btn-secondary:hover {
background: #ffffff;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 5px 11px;
font-size: 12px;
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
border-radius: 12px;
overflow: hidden;
}
th {
background: rgba(15, 110, 205, 0.12);
padding: 12px;
text-align: left;
font-size: 13px;
color: #19446f;
border-bottom: 1px solid rgba(15, 110, 205, 0.22);
}
td {
padding: 10px 12px;
border-bottom: 1px solid rgba(15, 35, 58, 0.08);
font-size: 13px;
color: #203a58;
vertical-align: top;
background: rgba(255, 255, 255, 0.42);
}
tr:hover td {
background: rgba(255, 255, 255, 0.74);
}
.prompt-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.tag-on {
background: rgba(15, 157, 88, 0.12);
color: #0b7f47;
border-color: rgba(15, 157, 88, 0.24);
}
.tag-off {
background: rgba(217, 72, 72, 0.12);
color: #b83b3b;
border-color: rgba(217, 72, 72, 0.24);
}
.modal {
display: none;
position: fixed;
inset: 0;
background: rgba(9, 20, 36, 0.34);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 20px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal.show {
display: flex;
}
.modal-body {
border-radius: 16px;
padding: 26px;
width: 620px;
max-width: 92vw;
max-height: 86vh;
overflow-y: auto;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.modal-body h3 {
margin-bottom: 20px;
color: var(--text);
}
.status-box {
padding: 14px;
border-radius: 10px;
margin-top: 16px;
font-size: 14px;
border: 1px solid transparent;
}
.status-info {
background: rgba(15, 110, 205, 0.10);
color: #0f5eab;
border-color: rgba(15, 110, 205, 0.24);
}
.status-success {
background: rgba(15, 157, 88, 0.10);
color: #0f7f4b;
border-color: rgba(15, 157, 88, 0.24);
}
.status-error {
background: rgba(217, 72, 72, 0.10);
color: #b43d3d;
border-color: rgba(217, 72, 72, 0.24);
}
.empty-state {
text-align: center;
padding: 42px;
color: var(--muted);
}
.result-card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
background: rgba(255, 255, 255, 0.6);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.result-card:hover {
border-color: rgba(15, 110, 205, 0.30);
box-shadow: 0 10px 26px rgba(15, 42, 74, 0.14);
}
.result-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 12px;
}
.result-card-header h4 {
font-size: 15px;
color: #17395b;
}
.result-card-header span {
font-size: 12px;
color: var(--muted);
}
.result-item {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(15, 35, 58, 0.08);
gap: 12px;
}
.result-item:last-child {
border-bottom: none;
}
.result-item .type {
min-width: 80px;
font-weight: 700;
color: var(--primary);
font-size: 13px;
}
.result-item .name {
flex: 1;
font-size: 13px;
color: #1f3957;
}
.result-item .amount {
min-width: 100px;
text-align: right;
font-weight: 700;
color: #ad5b1a;
font-size: 13px;
}
.result-item .date {
min-width: 90px;
text-align: center;
color: var(--muted);
font-size: 12px;
}
.result-item a {
color: var(--primary);
text-decoration: none;
font-size: 12px;
}
.result-item a:hover {
text-decoration: underline;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 10px;
}
.config-section {
background: rgba(255, 255, 255, 0.58);
border: 1px solid var(--line);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.config-section h3 {
margin-bottom: 14px;
font-size: 16px;
color: #19446f;
}
@media (max-width: 900px) {
body {
padding: 12px;
}
.content {
padding: 14px;
}
.form-row {
flex-direction: column;
gap: 10px;
}
.header {
padding: 20px 16px;
}
.header h1 {
font-size: 20px;
}
.tabs {
padding: 6px;
}
.tab {
font-size: 14px;
padding: 10px 6px;
}
.btn {
padding: 9px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>公告抓取与分析工具</h1>
<p>基于 Agent API 的智能公告信息采集</p>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('tasks')">任务配置</button>
<button class="tab" onclick="switchTab('results')">抓取结果</button>
<button class="tab" onclick="switchTab('settings')">系统设置</button>
</div>
<div class="content">
<!-- ===== 任务配置 Tab ===== -->
<div id="tab-tasks" class="tab-content active">
<div class="toolbar">
<div>
<button class="btn btn-primary" onclick="openTaskModal()">+ 新增任务</button>
<button class="btn btn-success" id="btnRunAll" onclick="runAllTasks()">运行全部启用</button>
</div>
<div style="font-size:13px;color:#888;" id="taskSummary"></div>
</div>
<div class="toolbar" style="margin-bottom: 14px;">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;width:100%;">
<input type="text" id="taskFilterCity" placeholder="搜索城市" oninput="onTaskFilterChange()" style="max-width:220px;">
<input type="text" id="taskFilterPlate" placeholder="搜索板块" oninput="onTaskFilterChange()" style="max-width:220px;">
</div>
</div>
<table>
<thead>
<tr>
<th>城市</th>
<th>板块</th>
<th>提示词</th>
<th>模型</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tasksTbody">
<tr>
<td colspan="6" class="empty-state">加载中...</td>
</tr>
</tbody>
</table>
<div id="tasksPagination" style="text-align:center;margin-top:16px;"></div>
<div id="batchRunStatus" class="status-box status-info" style="display:none;"></div>
</div>
<!-- ===== 抓取结果 Tab ===== -->
<div id="tab-results" class="tab-content">
<div class="toolbar">
<div style="display:flex;gap:10px;align-items:center;">
<select id="filterCity" onchange="loadResults()" style="width:auto;">
<option value="">全部城市</option>
</select>
<button class="btn btn-secondary btn-sm" onclick="loadResults()">刷新</button>
</div>
<button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button>
</div>
<div id="resultsList"></div>
<div id="resultsPagination" style="text-align:center;margin-top:16px;"></div>
</div>
<!-- ===== 系统设置 Tab ===== -->
<div id="tab-settings" class="tab-content">
<!-- Agent 配置 -->
<div class="config-section">
<h3>Agent 服务配置</h3>
<div class="form-row">
<div class="form-group">
<label>服务地址 (baseUrl)</label>
<input type="text" id="cfgBaseUrl" placeholder="http://192.168.3.65:18625">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>轮询间隔 (毫秒)</label>
<input type="number" id="cfgPollInterval" placeholder="3000">
</div>
<div class="form-group">
<label>超时时间 (毫秒)</label>
<input type="number" id="cfgTimeout" placeholder="300000">
</div>
</div>
</div>
<!-- 定时任务配置 -->
<div class="config-section">
<h3>定时任务</h3>
<div class="form-row">
<div class="form-group">
<label>启用</label>
<select id="cfgSchedulerEnabled">
<option value="false">禁用</option>
<option value="true">启用</option>
</select>
</div>
<div class="form-group">
<label>Cron 表达式</label>
<input type="text" id="cfgCronTime" placeholder="0 9 * * *">
</div>
<div class="form-group">
<label>描述</label>
<input type="text" id="cfgDescription" placeholder="每天9点采集">
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="triggerScheduledTask()">立即执行定时任务</button>
<span id="schedulerStatus" style="margin-left:12px;font-size:13px;color:#888;"></span>
</div>
<!-- 邮件配置 -->
<div class="config-section">
<h3>邮件配置</h3>
<div class="form-row">
<div class="form-group">
<label>SMTP 服务器</label>
<input type="text" id="cfgSmtpHost" placeholder="smtp.qq.com">
</div>
<div class="form-group">
<label>端口</label>
<input type="number" id="cfgSmtpPort" placeholder="587">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>用户名</label>
<input type="text" id="cfgSmtpUser" placeholder="your-email@qq.com">
</div>
<div class="form-group">
<label>密码/授权码</label>
<input type="password" id="cfgSmtpPass" placeholder="授权码">
</div>
</div>
<div class="form-group">
<label>收件人 (多个用逗号分隔)</label>
<input type="text" id="cfgRecipients" placeholder="a@example.com, b@example.com">
</div>
</div>
<button class="btn btn-primary" onclick="saveSettings()">保存全部设置</button>
<span id="settingsSaveStatus" style="margin-left:12px;font-size:13px;"></span>
</div>
</div>
</div>
<!-- 任务编辑弹窗 -->
<div class="modal" id="taskModal" onclick="if(event.target===this)closeTaskModal()">
<div class="modal-body">
<h3 id="taskModalTitle">新增任务</h3>
<form onsubmit="saveTask(event)">
<input type="hidden" id="taskEditId">
<div class="form-group">
<label>城市名称</label>
<input type="text" id="taskCity" placeholder="如:南京市" required>
</div>
<div class="form-group">
<label>板块名称</label>
<input type="text" id="plateName" placeholder="如:工程" required>
</div>
<div class="form-group">
<label>提示词 (在提示词中包含目标网址和抓取要求)</label>
<textarea id="taskPrompt" rows="6" placeholder="请访问 https://xxx.com 获取今天的所有招标公告和中标公告信息..."
required></textarea>
</div>
<div class="form-group">
<label>模型 (mode)</label>
<select id="taskMode">
<option value="qwen3.5-plus">qwen3.5-plus</option>
<option value="qwen3-max-2026-01-23">qwen3-max-2026-01-23</option>
<option value="qwen3-coder-next">qwen3-coder-next</option>
<option value="qwen3-coder-plus">qwen3-coder-plus</option>
<option value="glm-5">glm-5</option>
<option value="glm-4.7">glm-4.7</option>
<option value="kimi-k2.5">kimi-k2.5</option>
<option value="MiniMax-M2.5">MiniMax-M2.5</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="taskEnabled" checked> 启用
</label>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script>
let tasksList = [];
let currentTasksPage = 1;
const tasksPageSize = 10;
let currentResultsPage = 1;
// ===== Tab 切换 =====
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', ['tasks', 'results', 'settings'][i] === name);
});
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
if (name === 'results') loadResults();
if (name === 'settings') loadSettings();
}
// ===== 任务 CRUD =====
async function loadTasks() {
try {
const res = await fetch('/api/tasks');
const json = await res.json();
tasksList = json.data || [];
currentTasksPage = 1;
renderTasks();
} catch (e) {
console.error('加载任务失败:', e);
}
}
function getFilteredTasks() {
const cityKeyword = document.getElementById('taskFilterCity').value.trim().toLowerCase();
const plateKeyword = document.getElementById('taskFilterPlate').value.trim().toLowerCase();
return tasksList.filter(t => {
const city = (t.city || '').toLowerCase();
const plate = (t.plateName || '').toLowerCase();
const matchCity = !cityKeyword || city.includes(cityKeyword);
const matchPlate = !plateKeyword || plate.includes(plateKeyword);
return matchCity && matchPlate;
});
}
function onTaskFilterChange() {
currentTasksPage = 1;
renderTasks();
}
function renderTasks() {
const tbody = document.getElementById('tasksTbody');
const enabled = tasksList.filter(t => t.enabled).length;
const filtered = getFilteredTasks();
const totalPages = Math.max(1, Math.ceil(filtered.length / tasksPageSize));
if (currentTasksPage > totalPages) currentTasksPage = totalPages;
const startIdx = (currentTasksPage - 1) * tasksPageSize;
const pageData = filtered.slice(startIdx, startIdx + tasksPageSize);
document.getElementById('taskSummary').textContent =
`${tasksList.length} 个任务,${enabled} 个启用,筛选后 ${filtered.length}`;
if (filtered.length === 0) {
const hasKeyword = document.getElementById('taskFilterCity').value.trim() || document.getElementById('taskFilterPlate').value.trim();
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">${hasKeyword ? '没有匹配的任务' : '暂无任务,点击「新增任务」添加'}</td></tr>`;
document.getElementById('tasksPagination').innerHTML = '';
return;
}
tbody.innerHTML = pageData.map(t => `
<tr>
<td><strong>${t.city || '-'}</strong></td>
<td>${t.plateName || '-'}</td>
<td class="prompt-cell" title="${(t.prompt || '').replace(/"/g, '&quot;')}">${t.prompt || '-'}</td>
<td><code style="font-family:'Fira Code','Cascadia Code',monospace;font-size:12px;">${t.mode || 'qwen3.5-plus'}</code></td>
<td><span class="tag ${t.enabled ? 'tag-on' : 'tag-off'}">${t.enabled ? '启用' : '禁用'}</span></td>
<td>
<button class="btn btn-primary btn-sm" onclick="openTaskModal('${t.id}')">编辑</button>
<button class="btn btn-success btn-sm" onclick="runSingleTask('${t.id}', this)">运行</button>
<button class="btn btn-secondary btn-sm" onclick="toggleTask('${t.id}', ${!t.enabled})">${t.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-danger btn-sm" onclick="deleteTask('${t.id}')">删除</button>
</td>
</tr>
`).join('');
renderTasksPagination(filtered.length, totalPages);
}
function renderTasksPagination(totalFiltered, totalPages) {
const paginationDiv = document.getElementById('tasksPagination');
if (totalPages <= 1) {
paginationDiv.innerHTML = totalFiltered > 0
? `<span style="font-size:13px;color:#888;">共 ${totalFiltered} 条</span>`
: '';
return;
}
let html = '';
html += `<button class="btn btn-secondary btn-sm" onclick="goTasksPage(${currentTasksPage - 1})" ${currentTasksPage <= 1 ? 'disabled' : ''}>上一页</button>`;
const maxVisiblePages = 7;
let startPage = Math.max(1, currentTasksPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
if (startPage > 1) {
html += `<button class="btn btn-sm btn-secondary" onclick="goTasksPage(1)" style="margin:0 2px;">1</button>`;
if (startPage > 2) html += `<span style="color:#888;font-size:13px;margin:0 4px;">...</span>`;
}
for (let i = startPage; i <= endPage; i++) {
html += `<button class="btn btn-sm ${i === currentTasksPage ? 'btn-primary' : 'btn-secondary'}" onclick="goTasksPage(${i})" style="margin:0 2px;">${i}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += `<span style="color:#888;font-size:13px;margin:0 4px;">...</span>`;
html += `<button class="btn btn-sm btn-secondary" onclick="goTasksPage(${totalPages})" style="margin:0 2px;">${totalPages}</button>`;
}
html += `<button class="btn btn-secondary btn-sm" onclick="goTasksPage(${currentTasksPage + 1})" ${currentTasksPage >= totalPages ? 'disabled' : ''}>下一页</button>`;
html += `<span style="font-size:13px;color:#888;margin-left:8px;">第 ${currentTasksPage}/${totalPages} 页,共 ${totalFiltered} 条</span>`;
paginationDiv.innerHTML = html;
}
function goTasksPage(page) {
const totalPages = Math.max(1, Math.ceil(getFilteredTasks().length / tasksPageSize));
if (page < 1 || page > totalPages) return;
currentTasksPage = page;
renderTasks();
}
function openTaskModal(id) {
const item = id ? tasksList.find(t => t.id === id) : null;
document.getElementById('taskModalTitle').textContent = item ? '编辑任务' : '新增任务';
document.getElementById('taskEditId').value = item ? item.id : '';
document.getElementById('taskCity').value = item ? item.city : '';
document.getElementById('plateName').value = item ? item.plateName : '';
document.getElementById('taskPrompt').value = item ? item.prompt : '';
const modeSelect = document.getElementById('taskMode');
const modeValue = item?.mode || 'qwen3.5-plus';
if (![...modeSelect.options].some(opt => opt.value === modeValue)) {
const option = document.createElement('option');
option.value = modeValue;
option.textContent = modeValue;
modeSelect.appendChild(option);
}
modeSelect.value = modeValue;
document.getElementById('taskEnabled').checked = item ? item.enabled : true;
document.getElementById('taskModal').classList.add('show');
}
function closeTaskModal() {
document.getElementById('taskModal').classList.remove('show');
}
async function saveTask(e) {
e.preventDefault();
const id = document.getElementById('taskEditId').value;
const data = {
city: document.getElementById('taskCity').value.trim(),
plateName: document.getElementById('plateName').value.trim(),
prompt: document.getElementById('taskPrompt').value.trim(),
mode: document.getElementById('taskMode').value.trim() || 'qwen3.5-plus',
enabled: document.getElementById('taskEnabled').checked,
};
try {
const url = id ? `/api/tasks/${id}` : '/api/tasks';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
const json = await res.json();
if (!json.success) throw new Error(json.error);
closeTaskModal();
await loadTasks();
} catch (err) {
alert('保存失败: ' + err.message);
}
}
async function deleteTask(id) {
const item = tasksList.find(t => t.id === id);
if (!confirm(`确定要删除「${item?.city}」的任务吗?`)) return;
try {
const res = await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
const json = await res.json();
if (!json.success) throw new Error(json.error);
await loadTasks();
} catch (err) {
alert('删除失败: ' + err.message);
}
}
async function toggleTask(id, enabled) {
try {
const res = await fetch(`/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const json = await res.json();
if (!json.success) throw new Error(json.error);
await loadTasks();
} catch (err) {
alert('操作失败: ' + err.message);
}
}
// ===== 运行任务(异步 + 轮询状态) =====
let pollTimer = null;
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function startPolling(statusDiv, onFinished) {
stopPolling();
pollTimer = setInterval(async () => {
try {
const res = await fetch('/api/tasks/status');
const json = await res.json();
if (!json.success) return;
const s = json.data;
const elapsed = s.elapsed || 0;
const min = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const timeStr = min > 0 ? `${min}${sec}` : `${sec}`;
if (!s.finished) {
statusDiv.className = 'status-box status-info';
statusDiv.textContent = `正在执行: ${s.city || ''}${s.current}/${s.total})已用时 ${timeStr}...`;
} else {
stopPolling();
onFinished(s);
}
} catch (e) {
console.error('轮询状态失败:', e);
}
}, 2000);
}
async function runSingleTask(id, btnEl) {
const origText = btnEl.textContent;
btnEl.disabled = true;
btnEl.textContent = '运行中...';
const statusDiv = document.getElementById('batchRunStatus');
statusDiv.style.display = 'block';
statusDiv.className = 'status-box status-info';
statusDiv.textContent = '正在启动任务...';
try {
const res = await fetch(`/api/tasks/${id}/run`, { method: 'POST' });
const json = await res.json();
if (!json.success) throw new Error(json.error);
startPolling(statusDiv, (s) => {
btnEl.disabled = false;
btnEl.textContent = origText;
if (s.error) {
statusDiv.className = 'status-box status-error';
statusDiv.textContent = '运行失败: ' + s.error;
} else {
const total = s.results?.[0]?.data?.total || 0;
statusDiv.className = 'status-box status-success';
statusDiv.textContent = `运行完成!获取到 ${total} 条结果`;
}
});
} catch (err) {
btnEl.disabled = false;
btnEl.textContent = origText;
statusDiv.className = 'status-box status-error';
statusDiv.textContent = '启动失败: ' + err.message;
}
}
async function runAllTasks() {
const enabled = tasksList.filter(t => t.enabled);
if (enabled.length === 0) { alert('没有已启用的任务'); return; }
if (!confirm(`确定要运行全部 ${enabled.length} 个已启用的任务吗?`)) return;
const btn = document.getElementById('btnRunAll');
const statusDiv = document.getElementById('batchRunStatus');
btn.disabled = true;
btn.textContent = '运行中...';
statusDiv.style.display = 'block';
statusDiv.className = 'status-box status-info';
statusDiv.textContent = '正在启动任务...';
try {
const res = await fetch('/api/tasks/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const json = await res.json();
if (!json.success) throw new Error(json.error);
startPolling(statusDiv, (s) => {
btn.disabled = false;
btn.textContent = '运行全部启用';
const results = s.results || [];
const ok = results.filter(r => !r.error).length;
const fail = results.filter(r => r.error).length;
if (s.error) {
statusDiv.className = 'status-box status-error';
statusDiv.textContent = '运行失败: ' + s.error;
} else {
statusDiv.className = 'status-box status-success';
statusDiv.innerHTML = `运行完成!成功 <strong>${ok}</strong>,失败 <strong>${fail}</strong>`;
}
});
} catch (e) {
btn.disabled = false;
btn.textContent = '运行全部启用';
statusDiv.className = 'status-box status-error';
statusDiv.textContent = '启动失败: ' + e.message;
}
}
// ===== 结果展示 =====
async function loadResults() {
const city = document.getElementById('filterCity').value;
try {
// 加载筛选选项
const filtersRes = await fetch('/api/results/filters');
const filtersJson = await filtersRes.json();
if (filtersJson.success) {
const sel = document.getElementById('filterCity');
const curVal = sel.value;
sel.innerHTML = '<option value="">全部城市</option>' +
(filtersJson.data.cities || []).map(c => `<option value="${c}" ${c === curVal ? 'selected' : ''}>${c}</option>`).join('');
}
// 加载结果
const params = new URLSearchParams({ page: currentResultsPage, pageSize: 10 });
if (city) params.set('city', city);
const res = await fetch('/api/results?' + params);
const json = await res.json();
if (!json.success) throw new Error(json.error);
renderResults(json.data, json.total, json.page, json.pageSize);
} catch (e) {
document.getElementById('resultsList').innerHTML = `<div class="status-box status-error">加载失败: ${e.message}</div>`;
}
}
function renderResults(data, total, page, pageSize) {
const container = document.getElementById('resultsList');
if (!data || data.length === 0) {
container.innerHTML = '<div class="empty-state">暂无结果</div>';
document.getElementById('resultsPagination').innerHTML = '';
return;
}
container.innerHTML = data.map(r => {
const results = r.data?.results || [];
const errorHtml = r.error ? `<div style="color:#dc3545;font-size:13px;margin-top:8px;">错误: ${r.error}</div>` : '';
const itemsHtml = results.length > 0 ? results.map(item => `
<div class="result-item">
<span class="type">${item.type || '-'}</span>
<span class="name">${item.project_name || '-'}</span>
<span class="amount">${item.amount_yuan ? item.amount_yuan.toLocaleString() + ' 元' : '-'}</span>
<span class="date">${item.date || '-'}</span>
${(item.detail_link || item.target_link) ? `<a href="${item.detail_link || item.target_link}" target="_blank">查看</a>` : ''}
</div>
`).join('') : '<div style="padding:10px;color:#999;font-size:13px;">无数据</div>';
return `
<div class="result-card">
<div class="result-card-header">
<h4>${r.city || '未知城市'} <span style="font-weight:normal;color:#888;font-size:13px;">(${results.length} 条)</span></h4>
<span>${r.scrapedAt ? new Date(r.scrapedAt).toLocaleString('zh-CN') : ''}</span>
</div>
${itemsHtml}
${errorHtml}
<div style="text-align:right;margin-top:8px;">
<button class="btn btn-danger btn-sm" onclick="deleteResult('${r.id}')">删除</button>
</div>
</div>
`;
}).join('');
// 分页
const totalPages = Math.ceil(total / pageSize);
if (totalPages > 1) {
let pHtml = '';
for (let i = 1; i <= totalPages; i++) {
pHtml += `<button class="btn btn-sm ${i === page ? 'btn-primary' : 'btn-secondary'}" onclick="goPage(${i})" style="margin:0 3px;">${i}</button>`;
}
document.getElementById('resultsPagination').innerHTML = `<div>第 ${page}/${totalPages} 页,共 ${total} 条</div>${pHtml}`;
} else {
document.getElementById('resultsPagination').innerHTML = total > 0 ? `${total}` : '';
}
}
function goPage(p) { currentResultsPage = p; loadResults(); }
async function deleteResult(id) {
if (!confirm('确定删除这条结果?')) return;
try {
await fetch(`/api/results/${id}`, { method: 'DELETE' });
loadResults();
} catch (e) {
alert('删除失败: ' + e.message);
}
}
async function clearResults() {
if (!confirm('确定要清空全部结果吗?此操作不可恢复。')) return;
try {
await fetch('/api/results', { method: 'DELETE' });
loadResults();
} catch (e) {
alert('清空失败: ' + e.message);
}
}
// ===== 设置 =====
async function loadSettings() {
try {
const res = await fetch('/api/config');
const json = await res.json();
if (!json.success) return;
const cfg = json.data;
// Agent
document.getElementById('cfgBaseUrl').value = cfg.agent?.baseUrl || '';
document.getElementById('cfgPollInterval').value = cfg.agent?.pollInterval || 3000;
document.getElementById('cfgTimeout').value = cfg.agent?.timeout || 300000;
// Scheduler
document.getElementById('cfgSchedulerEnabled').value = String(cfg.scheduler?.enabled ?? false);
document.getElementById('cfgCronTime').value = cfg.scheduler?.cronTime || '0 9 * * *';
document.getElementById('cfgDescription').value = cfg.scheduler?.description || '';
// Email
document.getElementById('cfgSmtpHost').value = cfg.email?.smtpHost || '';
document.getElementById('cfgSmtpPort').value = cfg.email?.smtpPort || 587;
document.getElementById('cfgSmtpUser').value = cfg.email?.smtpUser || '';
document.getElementById('cfgSmtpPass').value = cfg.email?.smtpPass || '';
document.getElementById('cfgRecipients').value = cfg.email?.recipients || '';
// Scheduler status
const statusRes = await fetch('/api/scheduler/status');
const statusJson = await statusRes.json();
if (statusJson.success) {
const s = statusJson.data;
document.getElementById('schedulerStatus').textContent =
s.isRunning ? `运行中 (${s.enabledTasks} 个任务)` : '未运行';
}
} catch (e) {
console.error('加载设置失败:', e);
}
}
async function saveSettings() {
try {
const cfg = {
agent: {
baseUrl: document.getElementById('cfgBaseUrl').value.trim(),
pollInterval: parseInt(document.getElementById('cfgPollInterval').value) || 3000,
timeout: parseInt(document.getElementById('cfgTimeout').value) || 300000,
},
scheduler: {
enabled: document.getElementById('cfgSchedulerEnabled').value === 'true',
cronTime: document.getElementById('cfgCronTime').value.trim() || '0 9 * * *',
description: document.getElementById('cfgDescription').value.trim(),
},
email: {
smtpHost: document.getElementById('cfgSmtpHost').value.trim(),
smtpPort: parseInt(document.getElementById('cfgSmtpPort').value) || 587,
smtpUser: document.getElementById('cfgSmtpUser').value.trim(),
smtpPass: document.getElementById('cfgSmtpPass').value.trim(),
recipients: document.getElementById('cfgRecipients').value.trim(),
}
};
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
});
const json = await res.json();
if (!json.success) throw new Error(json.error);
const span = document.getElementById('settingsSaveStatus');
span.textContent = '已保存';
span.style.color = '#28a745';
setTimeout(() => { span.textContent = ''; }, 3000);
} catch (e) {
alert('保存失败: ' + e.message);
}
}
async function triggerScheduledTask() {
if (!confirm('确定要立即执行定时任务吗?')) return;
try {
const res = await fetch('/api/run-scheduled-task', { method: 'POST' });
const json = await res.json();
alert(json.message || '已触发');
} catch (e) {
alert('触发失败: ' + e.message);
}
}
// ===== 初始化 =====
loadTasks();
loadSettings();
</script>
</body>
</html>