Files
tool-node/public/index.html
zhaojunlong 052f3a137b ```
fix(public): 修复项目列表中section和type字段显示逻辑

修复了项目列表表格中section和type字段的显示顺序问题,
确保优先显示type字段内容,当type为空时才显示section字段内容
```
2026-03-30 18:35:40 +08:00

1722 lines
69 KiB
HTML
Raw Permalink 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('projects')">项目管理</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-projects" class="tab-content">
<div class="config-section" style="padding-bottom:16px;">
<div class="form-row">
<div class="form-group">
<label>城市</label>
<select id="projectFilterCity">
<option value="">全部城市</option>
</select> </div>
<div class="form-group">
<label>板块</label>
<input type="text" id="projectFilterSection" placeholder="请输入板块名称">
</div>
<div class="form-group">
<label>项目名称</label>
<input type="text" id="projectFilterProjectName" placeholder="请输入项目名称">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>最小金额</label>
<input type="number" id="projectFilterMinAmount" placeholder="最小金额" min="0" step="0.01">
</div>
<div class="form-group">
<label>最大金额</label>
<input type="number" id="projectFilterMaxAmount" placeholder="最大金额" min="0" step="0.01">
</div>
<div class="form-group">
<label>开始日期</label>
<input type="date" id="projectFilterStartDate">
</div>
<div class="form-group">
<label>结束日期</label>
<input type="date" id="projectFilterEndDate">
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="font-size:13px;color:#888;">按项目名称拆分并去重后展示</span>
<div style="display:flex;gap:10px;align-items:center;">
<button class="btn btn-primary btn-sm" onclick="searchProjectResults()">查询</button>
<button class="btn btn-secondary btn-sm" onclick="resetProjectFilters()">重置</button>
<button class="btn btn-success btn-sm" id="btnExportProjects" onclick="exportProjectResults(this)">导出</button>
<!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> -->
</div>
</div>
</div>
<div class="config-section" style="padding:0;overflow:hidden;">
<div style="padding:16px 20px;border-bottom:1px solid rgba(15, 35, 58, 0.08);font-size:13px;color:#66768a;" id="projectsSummary">加载中...</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th style="min-width:120px;">城市</th>
<th style="min-width:140px;">板块</th>
<th>项目名称</th>
<th style="min-width:150px;">金额</th>
<th style="min-width:120px;">发布日期</th>
<th style="min-width:120px;">详情链接</th>
</tr>
</thead>
<tbody id="projectsTbody">
<tr>
<td colspan="6" class="empty-state">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="projectsPagination" 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>执行方式</label>
<select id="cfgScheduleMode" onchange="handleSchedulerModeChange()">
<option value="daily">每天</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
<option value="custom">高级 Cron</option>
</select>
</div>
<div class="form-group">
<label>描述</label>
<input type="text" id="cfgDescription" placeholder="每天9点采集">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>执行时间</label>
<input type="time" id="cfgScheduleTime" value="09:00" onchange="updateSchedulerCronPreview()">
</div>
<div class="form-group" id="cfgWeekdayGroup" style="display:none;">
<label>星期</label>
<select id="cfgWeekday" onchange="updateSchedulerCronPreview()">
<option value="1">周一</option>
<option value="2">周二</option>
<option value="3">周三</option>
<option value="4">周四</option>
<option value="5">周五</option>
<option value="6">周六</option>
<option value="0">周日</option>
</select>
</div>
<div class="form-group" id="cfgMonthDayGroup" style="display:none;">
<label>每月几号</label>
<input type="number" id="cfgMonthDay" min="1" max="31" value="1" onchange="updateSchedulerCronPreview()">
</div>
</div>
<div class="form-row">
<div class="form-group" id="cfgCustomCronGroup" style="display:none;">
<label>高级 Cron 表达式</label>
<input type="text" id="cfgCronTime" placeholder="0 9 * * *" oninput="updateSchedulerCronPreview()">
</div>
<div class="form-group">
<label>计划预览</label>
<input type="text" id="cfgCronPreview" readonly placeholder="每天 09:00">
</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;
let currentProjectsPage = 1;
// ===== Tab 切换 =====
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', ['tasks', 'results', 'projects', '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 === 'projects') loadProjectResults();
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() {
try {
await loadResultCityOptions('filterCity');
const city = document.getElementById('filterCity').value;
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({
data: json.data,
total: json.total,
page: json.page,
pageSize: json.pageSize,
listId: 'resultsList',
paginationId: 'resultsPagination',
});
} catch (e) {
document.getElementById('resultsList').innerHTML = `<div class="status-box status-error">加载失败: ${e.message}</div>`;
document.getElementById('resultsPagination').innerHTML = '';
}
}
async function loadProjectResults() {
try {
const projectFilters = getProjectFilterValues();
validateProjectFilters(projectFilters);
await loadProjectFilters();
const params = buildProjectQueryParams({ projectFilters, includePagination: true });
const res = await fetch('/api/projects?' + params);
const json = await res.json();
if (!json.success) throw new Error(json.error);
renderProjects({
data: json.data,
total: json.total,
page: json.page,
pageSize: json.pageSize,
});
} catch (e) {
document.getElementById('projectsTbody').innerHTML = `<tr><td colspan="6" class="empty-state">加载失败: ${e.message}</td></tr>`;
document.getElementById('projectsSummary').textContent = '项目列表加载失败';
document.getElementById('projectsPagination').innerHTML = '';
}
}
async function loadProjectFilters() {
const filtersRes = await fetch('/api/projects/filters');
const filtersJson = await filtersRes.json();
if (!filtersJson.success) return;
populateSelectWithPlaceholder('projectFilterCity', '全部城市', filtersJson.data.cities || []);
}
async function loadResultCityOptions(selectId) {
const filtersRes = await fetch('/api/results/filters');
const filtersJson = await filtersRes.json();
if (!filtersJson.success) return;
populateSelectWithPlaceholder(selectId, '全部城市', filtersJson.data.cities || []);
}
function populateSelectWithPlaceholder(selectId, placeholder, items) {
const sel = document.getElementById(selectId);
const curVal = sel.value;
sel.innerHTML = `<option value="">${placeholder}</option>` +
items.map(item => `<option value="${item}" ${item === curVal ? 'selected' : ''}>${item}</option>`).join('');
}
function getProjectFilterValues() {
return {
city: document.getElementById('projectFilterCity').value.trim(),
section: document.getElementById('projectFilterSection').value.trim(),
projectName: document.getElementById('projectFilterProjectName').value.trim(),
minAmount: document.getElementById('projectFilterMinAmount').value.trim(),
maxAmount: document.getElementById('projectFilterMaxAmount').value.trim(),
startDate: document.getElementById('projectFilterStartDate').value.trim(),
endDate: document.getElementById('projectFilterEndDate').value.trim(),
};
}
function validateProjectFilters(projectFilters) {
if (projectFilters.minAmount && projectFilters.maxAmount && Number(projectFilters.minAmount) > Number(projectFilters.maxAmount)) {
throw new Error('最小金额不能大于最大金额');
}
if (projectFilters.startDate && projectFilters.endDate && projectFilters.startDate > projectFilters.endDate) {
throw new Error('开始日期不能晚于结束日期');
}
}
function buildProjectQueryParams({ projectFilters = getProjectFilterValues(), includePagination = true } = {}) {
const params = new URLSearchParams();
if (includePagination) {
params.set('page', currentProjectsPage);
params.set('pageSize', 10);
}
Object.entries(projectFilters).forEach(([key, value]) => {
if (value !== '') params.set(key, value);
});
return params;
}
function searchProjectResults() {
currentProjectsPage = 1;
loadProjectResults();
}
function resetProjectFilters() {
[
'projectFilterCity',
'projectFilterSection',
'projectFilterProjectName',
'projectFilterMinAmount',
'projectFilterMaxAmount',
'projectFilterStartDate',
'projectFilterEndDate',
].forEach((id) => {
document.getElementById(id).value = '';
});
currentProjectsPage = 1;
loadProjectResults();
}
async function exportProjectResults(buttonEl) {
const btn = buttonEl || document.getElementById('btnExportProjects');
const originalText = btn ? btn.textContent : '导出';
try {
const projectFilters = getProjectFilterValues();
validateProjectFilters(projectFilters);
if (btn) {
btn.disabled = true;
btn.textContent = '导出中...';
}
const res = await fetch('/api/projects/export?' + buildProjectQueryParams({
projectFilters,
includePagination: false,
}));
if (!res.ok) {
let message = '导出失败';
try {
const json = await res.json();
message = json.error || message;
} catch (_error) {
// Ignore JSON parse failure and use the fallback message.
}
throw new Error(message);
}
const blob = await res.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = getDownloadFilename(
res.headers.get('content-disposition'),
`projects-${new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-')}.csv`
);
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
} catch (e) {
alert('导出失败: ' + e.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText;
}
}
}
function getDownloadFilename(contentDisposition, fallbackName) {
if (!contentDisposition) return fallbackName;
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match && utf8Match[1]) {
return decodeURIComponent(utf8Match[1]);
}
const asciiMatch = contentDisposition.match(/filename="([^"]+)"/i) || contentDisposition.match(/filename=([^;]+)/i);
if (asciiMatch && asciiMatch[1]) {
return asciiMatch[1].trim();
}
return fallbackName;
}
function renderResults({ data, total, page, pageSize, listId, paginationId }) {
const container = document.getElementById(listId);
if (!data || data.length === 0) {
container.innerHTML = '<div class="empty-state">暂无结果</div>';
document.getElementById(paginationId).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 || item.projectName || item.title || '-'}</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('');
renderPagination({ total, page, pageSize, paginationId, view: 'results' });
}
function renderProjects({ data, total, page, pageSize }) {
const tbody = document.getElementById('projectsTbody');
const summary = document.getElementById('projectsSummary');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">暂无去重后的项目</td></tr>';
summary.textContent = '当前筛选条件下没有项目';
document.getElementById('projectsPagination').innerHTML = '';
return;
}
const startIndex = (page - 1) * pageSize + 1;
const endIndex = Math.min(page * pageSize, total);
summary.textContent = `显示第 ${startIndex}${endIndex} 条记录,共 ${total} 条记录`;
tbody.innerHTML = data.map(project => `
<tr>
<td>${escHtml(project.city || '-')}</td>
<td>${escHtml( project.type ||project.section || '-')}</td>
<td>${escHtml(project.projectName || '-')}</td>
<td>${formatAmount(project.amountYuan)}</td>
<td>${escHtml(project.date || '-')}</td>
<td>${project.detailLink ? `<a href="${project.detailLink}" target="_blank">查看详情</a>` : '-'}</td>
</tr>
`).join('');
renderPagination({ total, page, pageSize, paginationId: 'projectsPagination', view: 'projects' });
}
function renderPagination({ total, page, pageSize, paginationId, view }) {
const totalPages = Math.ceil(total / pageSize);
const pagination = document.getElementById(paginationId);
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('${view}', ${i})" style="margin:0 3px;">${i}</button>`;
}
pagination.innerHTML = `<div>第 ${page}/${totalPages} 页,共 ${total} 条</div>${pHtml}`;
} else {
pagination.innerHTML = total > 0 ? `${total}` : '';
}
}
function goPage(view, p) {
if (view === 'projects') {
currentProjectsPage = p;
loadProjectResults();
return;
}
currentResultsPage = p;
loadResults();
}
async function deleteResult(id) {
if (!confirm('确定删除这条结果?')) return;
try {
await fetch(`/api/results/${id}`, { method: 'DELETE' });
await Promise.all([loadResults(), loadProjectResults()]);
} catch (e) {
alert('删除失败: ' + e.message);
}
}
async function clearResults() {
if (!confirm('确定要清空全部结果吗?此操作不可恢复。')) return;
try {
await fetch('/api/results', { method: 'DELETE' });
currentResultsPage = 1;
currentProjectsPage = 1;
await Promise.all([loadResults(), loadProjectResults()]);
} catch (e) {
alert('清空失败: ' + e.message);
}
}
function formatAmount(amount) {
if (typeof amount !== 'number' || !Number.isFinite(amount)) return '-';
return `¥ ${amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
function escHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function padSchedulerNumber(value) {
return String(value).padStart(2, '0');
}
function normalizeSchedulerTime(value) {
const match = /^(\d{1,2}):(\d{1,2})$/.exec(String(value || '').trim());
if (!match) return '09:00';
const hour = Math.min(23, Math.max(0, parseInt(match[1], 10)));
const minute = Math.min(59, Math.max(0, parseInt(match[2], 10)));
return `${padSchedulerNumber(hour)}:${padSchedulerNumber(minute)}`;
}
function splitSchedulerTime(value) {
const [hour, minute] = normalizeSchedulerTime(value).split(':');
return { hour, minute };
}
function describeScheduler(mode, time, weekday, monthDay, cronTime) {
const normalizedTime = normalizeSchedulerTime(time);
const weekdayMap = {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
};
if (mode === 'daily') return `每天 ${normalizedTime}`;
if (mode === 'weekly') return `每周${weekdayMap[String(weekday)] || '周一'} ${normalizedTime}`;
if (mode === 'monthly') return `每月 ${monthDay}${normalizedTime}`;
return cronTime || '0 9 * * *';
}
function parseSchedulerCron(cronTime) {
const normalizedCron = String(cronTime || '0 9 * * *').trim().replace(/\s+/g, ' ');
const parts = normalizedCron.split(' ');
const fallback = {
mode: 'custom',
time: '09:00',
weekday: '1',
monthDay: '1',
cronTime: normalizedCron || '0 9 * * *',
};
if (parts.length !== 5) return fallback;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return fallback;
const time = `${padSchedulerNumber(parseInt(hour, 10))}:${padSchedulerNumber(parseInt(minute, 10))}`;
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return { mode: 'daily', time, weekday: '1', monthDay: '1', cronTime: normalizedCron };
}
if (dayOfMonth === '*' && month === '*' && /^(0|1|2|3|4|5|6|7)$/.test(dayOfWeek)) {
return {
mode: 'weekly',
time,
weekday: dayOfWeek === '7' ? '0' : dayOfWeek,
monthDay: '1',
cronTime: normalizedCron,
};
}
if (/^\d+$/.test(dayOfMonth) && month === '*' && dayOfWeek === '*') {
const normalizedMonthDay = Math.min(31, Math.max(1, parseInt(dayOfMonth, 10)));
return {
mode: 'monthly',
time,
weekday: '1',
monthDay: String(normalizedMonthDay),
cronTime: normalizedCron,
};
}
return fallback;
}
function buildSchedulerCronFromInputs() {
const mode = document.getElementById('cfgScheduleMode').value;
const time = normalizeSchedulerTime(document.getElementById('cfgScheduleTime').value);
const { hour, minute } = splitSchedulerTime(time);
const weekday = document.getElementById('cfgWeekday').value || '1';
const monthDayValue = parseInt(document.getElementById('cfgMonthDay').value, 10);
const monthDay = Number.isFinite(monthDayValue) ? Math.min(31, Math.max(1, monthDayValue)) : 1;
if (mode === 'daily') return `${minute} ${hour} * * *`;
if (mode === 'weekly') return `${minute} ${hour} * * ${weekday}`;
if (mode === 'monthly') return `${minute} ${hour} ${monthDay} * *`;
return document.getElementById('cfgCronTime').value.trim() || '0 9 * * *';
}
function updateSchedulerCronPreview() {
const cronTime = buildSchedulerCronFromInputs();
document.getElementById('cfgCronTime').value = cronTime;
document.getElementById('cfgCronPreview').value = describeScheduler(
document.getElementById('cfgScheduleMode').value,
document.getElementById('cfgScheduleTime').value,
document.getElementById('cfgWeekday').value,
document.getElementById('cfgMonthDay').value,
cronTime
);
}
function handleSchedulerModeChange() {
const mode = document.getElementById('cfgScheduleMode').value;
document.getElementById('cfgWeekdayGroup').style.display = mode === 'weekly' ? '' : 'none';
document.getElementById('cfgMonthDayGroup').style.display = mode === 'monthly' ? '' : 'none';
document.getElementById('cfgCustomCronGroup').style.display = mode === 'custom' ? '' : 'none';
updateSchedulerCronPreview();
}
// ===== 设置 =====
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);
const schedulerUi = parseSchedulerCron(cfg.scheduler?.cronTime || '0 9 * * *');
document.getElementById('cfgScheduleMode').value = schedulerUi.mode;
document.getElementById('cfgScheduleTime').value = schedulerUi.time;
document.getElementById('cfgWeekday').value = schedulerUi.weekday;
document.getElementById('cfgMonthDay').value = schedulerUi.monthDay;
document.getElementById('cfgCronTime').value = schedulerUi.cronTime;
document.getElementById('cfgDescription').value = cfg.scheduler?.description || '';
handleSchedulerModeChange();
// 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 cronTime = buildSchedulerCronFromInputs();
if (document.getElementById('cfgScheduleMode').value === 'custom') {
const cronParts = cronTime.trim().split(/\s+/);
if (cronParts.length !== 5) {
throw new Error('Cron 表达式格式错误,请填写 5 段');
}
}
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,
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);
}
}
function initProjectSearchInputs() {
[
'projectFilterCity',
'projectFilterSection',
'projectFilterProjectName',
'projectFilterMinAmount',
'projectFilterMaxAmount',
'projectFilterStartDate',
'projectFilterEndDate',
].forEach((id) => {
const element = document.getElementById(id);
if (!element) return;
element.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
searchProjectResults();
}
});
});
}
// ===== 初始化 =====
initProjectSearchInputs();
loadTasks();
handleSchedulerModeChange();
loadSettings();
</script>
</body>
</html>