Files
tool-node/public/index.html

1216 lines
44 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>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.tabs {
display: flex;
background: #f5f5f5;
border-bottom: 2px solid #e0e0e0;
}
.tab {
flex: 1;
padding: 15px;
text-align: center;
cursor: pointer;
background: #f5f5f5;
border: none;
font-size: 16px;
transition: all 0.3s;
}
.tab:hover {
background: #e8e8e8;
}
.tab.active {
background: white;
color: #667eea;
font-weight: bold;
border-bottom: 3px solid #667eea;
}
.content {
padding: 30px;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border 0.3s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 30px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin-right: 10px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
display: none;
text-align: center;
padding: 20px;
color: #667eea;
}
.loading.active {
display: block;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.results {
margin-top: 20px;
}
.list-item {
background: #f9f9f9;
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.list-item h3 {
font-size: 16px;
margin-bottom: 8px;
color: #333;
}
.list-item .meta {
font-size: 14px;
color: #666;
margin-bottom: 5px;
}
.list-item .budget {
display: inline-block;
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
margin-top: 8px;
}
.list-item a {
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.list-item a:hover {
text-decoration: underline;
}
.summary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.summary h2 {
margin-bottom: 15px;
font-size: 20px;
}
.summary .stat {
display: inline-block;
margin-right: 30px;
margin-bottom: 10px;
}
.summary .stat-label {
opacity: 0.9;
font-size: 14px;
}
.summary .stat-value {
font-size: 24px;
font-weight: bold;
margin-top: 5px;
}
.export-btn {
background: #28a745;
margin-top: 10px;
}
.export-btn:hover {
box-shadow: 0 5px 15px rgba(40, 167, 69, 0.4);
}
.simple-list {
max-height: 500px;
overflow-y: auto;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 8px;
margin-top: 10px;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #f8f9ff;
border: 2px solid #e0e5ff;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
user-select: none;
}
.checkbox-wrapper:hover {
background: #eef1ff;
border-color: #667eea;
}
.checkbox-wrapper input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #667eea;
}
.checkbox-wrapper label {
margin: 0;
cursor: pointer;
font-size: 15px;
color: #333;
font-weight: 500;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 20px;
padding: 20px 0;
}
.pagination button {
background: white;
border: 2px solid #667eea;
color: #667eea;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
min-width: 40px;
}
.pagination button:hover:not(:disabled) {
background: #667eea;
color: white;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination .page-info {
color: #666;
font-size: 14px;
margin: 0 10px;
}
/* ===== 抓取来源配置页样式 ===== */
.scrapers-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.scrapers-toolbar h2 {
margin: 0;
color: #667eea;
font-size: 20px;
}
.btn-add {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
border: none;
padding: 10px 22px;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(17, 153, 142, 0.3);
}
.btn-add:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(17, 153, 142, 0.4);
}
.scrapers-table-wrap {
overflow-x: auto;
border-radius: 12px;
border: 1px solid #e8eaf0;
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.06);
}
.scrapers-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
min-width: 800px;
}
.scrapers-table thead tr {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.scrapers-table th {
padding: 14px 14px;
text-align: left;
font-weight: 600;
white-space: nowrap;
}
.scrapers-table tbody tr {
border-bottom: 1px solid #f0f0f5;
transition: background 0.15s;
}
.scrapers-table tbody tr:last-child {
border-bottom: none;
}
.scrapers-table tbody tr:hover {
background: #f5f7ff;
}
.scrapers-table td {
padding: 12px 14px;
vertical-align: top;
color: #333;
}
.scrapers-table td.prompt-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #666;
font-size: 13px;
}
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.tag-type {
background: #e8f4fd;
color: #1a73c8;
}
.tag-enabled {
background: #e4f9ee;
color: #1a8a4a;
}
.tag-disabled {
background: #feeaea;
color: #c0392b;
}
.url-cell a {
color: #667eea;
text-decoration: none;
font-size: 12px;
word-break: break-all;
}
.url-cell a:hover {
text-decoration: underline;
}
.action-btns {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.btn-sm {
padding: 5px 12px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-edit {
background: #fff3cd;
color: #856404;
}
.btn-edit:hover {
background: #ffc107;
color: #fff;
}
.btn-delete {
background: #fdeaea;
color: #c0392b;
}
.btn-delete:hover {
background: #e74c3c;
color: #fff;
}
.btn-run {
background: #e8f4fd;
color: #1a73c8;
}
.btn-run:hover {
background: #667eea;
color: #fff;
}
.btn-toggle-on {
background: #e4f9ee;
color: #1a8a4a;
}
.btn-toggle-on:hover {
background: #27ae60;
color: #fff;
}
.btn-toggle-off {
background: #feeaea;
color: #c0392b;
}
.btn-toggle-off:hover {
background: #e74c3c;
color: #fff;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.empty-state svg {
margin-bottom: 12px;
opacity: 0.4;
}
/* 弹窗 */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal-box {
background: white;
border-radius: 16px;
padding: 32px;
width: 600px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: modalIn 0.2s ease;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.modal-header h3 {
margin: 0;
color: #333;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
line-height: 1;
padding: 0;
}
.modal-close:hover {
color: #333;
}
.modal-form .form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.modal-form .form-group {
margin-bottom: 16px;
}
.modal-form .form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #555;
margin-bottom: 6px;
}
.modal-form .form-group input,
.modal-form .form-group select,
.modal-form .form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
transition: border 0.2s;
box-sizing: border-box;
}
.modal-form .form-group input:focus,
.modal-form .form-group select:focus,
.modal-form .form-group textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.12);
}
.modal-form .form-group textarea {
resize: vertical;
min-height: 90px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.btn-cancel {
background: #f0f0f0;
color: #555;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
font-weight: 600;
}
.btn-cancel:hover {
background: #e0e0e0;
}
.btn-save {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 28px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.btn-save:hover {
box-shadow: 0 4px 14px rgba(102, 126, 234, 0.4);
}
.run-result {
margin-top: 16px;
padding: 14px;
background: #f7f8ff;
border-radius: 8px;
border: 1px solid #e0e5ff;
font-size: 13px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: #333;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>南京公共资源交易平台</h1>
<p>交通水务中标结果公示 - 中标价格采集工具</p>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('scheduler')">定时任务</button>
<button class="tab" onclick="switchTab('email')">邮件配置</button>
<button class="tab" onclick="switchTab('scrapers')">抓取来源</button>
<a href="/results.html" target="_blank" class="tab" style="text-decoration:none;color:inherit;">📊 抓取结果</a>
</div>
<div class="content">
<!-- 定时任务 -->
<div id="scheduler" class="tab-content active">
<h2 style="margin-bottom: 20px; color: #667eea;">定时任务配置</h2>
<p style="color: #666; margin-bottom: 20px;">配置定时任务自动采集大于指定金额的项目并发送邮件报告</p>
<!-- 任务状态 -->
<div id="schedulerStatus"
style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px;">
<h3 style="margin-top: 0; margin-bottom: 15px;">任务状态</h3>
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
<div>
<div style="opacity: 0.9; font-size: 14px;">运行状态</div>
<div style="font-size: 20px; font-weight: bold; margin-top: 5px;"
id="schedulerRunningStatus">加载中...</div>
</div>
<div>
<div style="opacity: 0.9; font-size: 14px;">执行时间</div>
<div style="font-size: 20px; font-weight: bold; margin-top: 5px;" id="schedulerCronTime">-
</div>
</div>
<div>
<div style="opacity: 0.9; font-size: 14px;">已启用来源</div>
<div style="font-size: 20px; font-weight: bold; margin-top: 5px;"
id="schedulerEnabledCount">-</div>
</div>
</div>
</div>
<!-- 配置表单 -->
<div class="form-group">
<div class="checkbox-wrapper" onclick="document.getElementById('schedulerEnabled').click();">
<input type="checkbox" id="schedulerEnabled" onclick="event.stopPropagation();">
<label for="schedulerEnabled">启用定时任务</label>
</div>
</div>
<div class="form-group">
<label>执行计划</label>
<select id="schedulerCronPreset" onchange="handleCronPresetChange()">
<option value="0 9 * * *">每天上午9点</option>
<option value="0 6 * * *">每天上午6点</option>
<option value="0 12 * * *">每天中午12点</option>
<option value="0 18 * * *">每天下午18点</option>
<option value="0 9,18 * * *">每天9点和18点</option>
<option value="0 */6 * * *">每6小时</option>
<option value="0 */12 * * *">每12小时</option>
<option value="0 9 * * 1">每周一上午9点</option>
<option value="0 9 1 * *">每月1日上午9点</option>
<option value="custom">自定义时间...</option>
</select>
</div>
<!-- 自定义时间配置 -->
<div class="form-group" id="customCronGroup" style="display: none;">
<label>自定义执行时间</label>
<div style="display: flex; gap: 10px; align-items: center;">
<div style="flex: 1;">
<label style="font-size: 12px; color: #666;">小时 (0-23)</label>
<input type="number" id="customHour" min="0" max="23" value="9">
</div>
<div style="flex: 1;">
<label style="font-size: 12px; color: #666;">分钟 (0-59)</label>
<input type="number" id="customMinute" min="0" max="59" value="0">
</div>
</div>
<small style="color: #666; display: block; margin-top: 5px;">
将在每天指定的时间执行
</small>
</div>
<!-- 隐藏的Cron表达式字段 -->
<input type="hidden" id="schedulerCronInput" value="0 9 * * *">
<div class="form-group">
<label>金额阈值(万元)- 邮件报告中只显示大于此金额的条目0 表示不筛选</label>
<input type="number" id="schedulerThresholdInput" value="0" min="0" step="1000">
<small style="color: #666; display: block; margin-top: 5px;">
10亿 = 100000万元 | 1亿 = 10000万元 | 0 = 不筛选,全部显示
</small>
</div>
<div class="form-group">
<label>任务描述 (可选)</label>
<input type="text" id="schedulerDescription" placeholder="例如: 每天9点自动抓取所有启用来源">
</div>
<button class="btn" onclick="saveSchedulerConfig()">保存配置</button>
<button class="btn" onclick="testSchedulerNow()"
style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">立即测试运行</button>
<button class="btn" onclick="loadSchedulerConfig()" style="background: #6c757d;">刷新状态</button>
<div id="schedulerConfigStatus" style="margin-top: 20px;"></div>
<div
style="margin-top: 30px; padding: 20px; background: #fff3cd; border-radius: 8px; border-left: 4px solid #ffc107;">
<h3 style="margin-top: 0; color: #856404;">使用说明</h3>
<ul style="line-height: 1.8; color: #856404;">
<li><strong>数据来源:</strong> 运行「抓取来源」页中所有已启用的抓取配置</li>
<li><strong>自动抓取:</strong> 按计划时间自动逐个运行所有启用的抓取来源,结果保存到「抓取结果」页</li>
<li><strong>邮件通知:</strong> 抓取完成后自动将结果发送到配置的邮箱(需先完成邮件配置)</li>
<li><strong>提示:</strong> 请前往「抓取来源」页配置并启用需要定时抓取的来源</li>
</ul>
</div>
</div>
<!-- 邮件配置 -->
<div id="email" class="tab-content">
<h2 style="margin-bottom: 20px; color: #667eea;">邮件配置</h2>
<p style="color: #666; margin-bottom: 20px;">配置SMTP邮件服务器信息,用于发送报告到指定邮箱</p>
<div class="form-group">
<label>SMTP服务器地址 *</label>
<input type="text" id="smtpHost" placeholder="例如: smtp.qq.com, smtp.163.com, smtp.gmail.com">
</div>
<div class="form-group">
<label>SMTP端口 *</label>
<input type="number" id="smtpPort" value="587" placeholder="通常为 587 (TLS) 或 465 (SSL)">
</div>
<div class="form-group">
<label>发件人邮箱 (SMTP用户名) *</label>
<input type="email" id="smtpUser" placeholder="your-email@example.com">
</div>
<div class="form-group">
<label>SMTP密码/授权码 *</label>
<input type="password" id="smtpPass" placeholder="邮箱密码或授权码">
</div>
<div class="form-group">
<label>收件人邮箱 (多个用逗号分隔) *</label>
<input type="text" id="recipients" placeholder="email1@example.com, email2@example.com">
</div>
<button class="btn" onclick="saveEmailConfig()">保存配置</button>
<button class="btn" onclick="testEmailConfig()"
style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">测试连接</button>
<div id="emailConfigStatus" style="margin-top: 20px;"></div>
<div
style="margin-top: 30px; padding: 20px; background: #f0f8ff; border-radius: 8px; border-left: 4px solid #667eea;">
<h3 style="margin-top: 0; color: #667eea;">常用邮箱配置参考</h3>
<ul style="line-height: 1.8; color: #666;">
<li><strong>QQ邮箱:</strong> smtp.qq.com, 端口 587 或 465, 需要使用授权码</li>
<li><strong>163邮箱:</strong> smtp.163.com, 端口 465 或 25, 需要使用授权码</li>
<li><strong>Gmail:</strong> smtp.gmail.com, 端口 587 或 465</li>
<li><strong>Outlook:</strong> smtp-mail.outlook.com, 端口 587</li>
</ul>
<p style="margin: 10px 0 0 0; color: #999; font-size: 13px;">
提示: QQ和163邮箱需要在邮箱设置中开启SMTP服务并生成授权码,授权码不是邮箱密码。
</p>
</div>
</div>
</div>
<!-- 抓取来源配置 -->
<div id="scrapers" class="tab-content" style="padding:30px ;">
<div class="scrapers-toolbar">
<h2>抓取来源配置</h2>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="btn-add" onclick="runAllScrapers()" id="btnRunAll"
style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"
viewBox="0 0 24 24">
<polygon points="5,3 19,12 5,21" />
</svg>
运行全部启用
</button>
<a href="/results.html" target="_blank" class="btn-add"
style="background:linear-gradient(135deg,#11998e 0%,#38ef7d 100%);text-decoration:none;">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"
viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M3 9h18M9 21V9" />
</svg>
查看结果
</a>
<button class="btn-add" onclick="openScraperModal()" style="cursor:pointer;">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"
viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
新增来源
</button>
</div>
</div>
<p style="color:#888;font-size:13px;margin:-8px 0 18px;">通过配置 URL 和提示词,使用 Firecrawl Agent
抓取任意网页数据。结果会自动保存,可在「抓取结果」页查看历史。</p>
<div class="scrapers-table-wrap">
<table class="scrapers-table">
<thead>
<tr>
<th style="width:80px">城市</th>
<th style="width:80px">板块</th>
<th style="width:70px">子板块</th>
<th style="width:80px">类型</th>
<th>链接地址</th>
<th>提示词</th>
<th style="width:70px">AI模型</th>
<th style="width:60px">状态</th>
<th style="width:180px">操作</th>
</tr>
</thead>
<tbody id="scrapersTbody">
<tr id="scrapers-empty-row">
<td colspan="9" class="empty-state">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5"
viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M3 9h18M9 21V9" />
</svg>
<div>暂无配置,点击「新增来源」添加抓取任务</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 批量运行状态 -->
<div id="batchRunStatus"
style="display:none;margin-top:16px;padding:14px;background:#f7f8ff;border-radius:8px;border:1px solid #e0e5ff;font-size:13px;color:#333;">
</div>
<!-- 测试结果展示 -->
<div id="scraperRunResult" style="display:none;">
<div style="margin-top:20px;font-size:14px;font-weight:600;color:#333;margin-bottom:8px;">📋 测试抓取结果
</div>
<div class="run-result" id="scraperRunResultContent"></div>
</div>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<div class="modal-overlay" id="scraperModal">
<div class="modal-box">
<div class="modal-header">
<h3 id="scraperModalTitle">新增抓取来源</h3>
<button class="modal-close" onclick="closeScraperModal()">×</button>
</div>
<form class="modal-form" onsubmit="saveScraperItem(event)">
<input type="hidden" id="scraperEditId">
<div class="form-row">
<div class="form-group">
<label>城市 *</label>
<input type="text" id="scraperCity" placeholder="例: 南京市" required>
</div>
<div class="form-group">
<label>板块</label>
<input type="text" id="scraperSection" placeholder="例: 交通水务">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>子板块</label>
<input type="text" id="scraperSubsection" placeholder="例: 建设工程">
</div>
<div class="form-group">
<label>类型</label>
<input type="text" id="scraperType" placeholder="例: 招标公告">
</div>
</div>
<div class="form-group">
<label>链接地址 *</label>
<input type="url" id="scraperUrl" placeholder="https://..." required>
</div>
<div class="form-group">
<label>提示词Agent 指令)*</label>
<textarea id="scraperPrompt"
placeholder="提取页面上今日的招标公告信息,包括:标题、项目金额(可能为合同预估价/最高投标限价等等、发布日期YYYY-MM-DD格式、详情页完整URL"
required></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>AI 模型</label>
<select id="scraperModel">
<option value="spark-1-mini">spark-1-mini默认</option>
<option value="spark-2">spark-2</option>
<option value="gpt-4o-mini">gpt-4o-mini</option>
<option value="claude-3-haiku">claude-3-haiku</option>
</select>
</div>
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:2px;">
<div class="checkbox-wrapper" style="width:100%;"
onclick="document.getElementById('scraperEnabled').click();">
<input type="checkbox" id="scraperEnabled" checked onclick="event.stopPropagation();">
<label for="scraperEnabled">启用此来源</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-cancel" onclick="closeScraperModal()">取消</button>
<button type="submit" class="btn-save">保存</button>
</div>
</form>
</div>
</div>
<!-- docx库已改为按需加载,只在用户点击导出时才加载,提升首屏加载速度 -->
<script src="app.js" defer></script>
<script>
// ===== 抓取来源配置 JS =====
let scrapersList = [];
async function loadScrapers() {
try {
const res = await fetch('/api/scrapers');
const json = await res.json();
scrapersList = json.data || [];
renderScrapers();
} catch (e) {
console.error('加载抓取来源失败:', e);
}
}
function renderScrapers() {
const tbody = document.getElementById('scrapersTbody');
if (scrapersList.length === 0) {
tbody.innerHTML = `<tr id="scrapers-empty-row"><td colspan="9" class="empty-state">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M3 9h18M9 21V9"/></svg>
<div>暂无配置,点击「新增来源」添加抓取任务</div></td></tr>`;
return;
}
tbody.innerHTML = scrapersList.map(s => `
<tr>
<td>${s.city || '-'}</td>
<td>${s.section || '-'}</td>
<td>${s.subsection || '-'}</td>
<td><span class="tag tag-type">${s.type || ''}</span></td>
<td class="url-cell"><a href="${s.url}" target="_blank" title="${s.url}">${s.url.replace(/^https?:\/\//, '').substring(0, 35)}${s.url.length > 40 ? '...' : ''}</a></td>
<td class="prompt-cell" title="${(s.prompt || '').replace(/"/g, '&quot;')}">${s.prompt || '-'}</td>
<td style="font-size:12px;color:#888;">${s.model || 'spark-1-mini'}</td>
<td><span class="tag ${s.enabled ? 'tag-enabled' : 'tag-disabled'}">${s.enabled ? '启用' : '禁用'}</span></td>
<td>
<div class="action-btns">
<button class="btn-sm btn-edit" onclick="openScraperModal('${s.id}')" title="编辑">编辑</button>
<button class="btn-sm btn-run" onclick="runScraper('${s.id}')" title="测试运行">测试</button>
<button class="btn-sm ${s.enabled ? 'btn-toggle-on' : 'btn-toggle-off'}" onclick="toggleScraper('${s.id}', ${!s.enabled})" title="切换启用状态">${s.enabled ? '禁用' : '启用'}</button>
<button class="btn-sm btn-delete" onclick="deleteScraper('${s.id}')" title="删除">删除</button>
</div>
</td>
</tr>
`).join('');
}
function openScraperModal(id) {
const item = id ? scrapersList.find(s => s.id === id) : null;
document.getElementById('scraperModalTitle').textContent = item ? '编辑抓取来源' : '新增抓取来源';
document.getElementById('scraperEditId').value = item ? item.id : '';
document.getElementById('scraperCity').value = item ? item.city : '';
document.getElementById('scraperSection').value = item ? item.section : '';
document.getElementById('scraperSubsection').value = item ? item.subsection : '';
document.getElementById('scraperType').value = item ? item.type : '招标公告';
document.getElementById('scraperUrl').value = item ? item.url : '';
document.getElementById('scraperPrompt').value = item ? item.prompt : '提取页面上今日的招标公告信息,包括:标题、项目金额(可能为合同预估价/最高投标限价等等、发布日期YYYY-MM-DD格式、详情页完整URL';
document.getElementById('scraperModel').value = item ? (item.model || 'spark-1-mini') : 'spark-1-mini';
document.getElementById('scraperEnabled').checked = item ? item.enabled : true;
document.getElementById('scraperRunResult').style.display = 'none';
document.getElementById('scraperModal').classList.add('show');
}
function closeScraperModal() {
document.getElementById('scraperModal').classList.remove('show');
}
async function saveScraperItem(e) {
e.preventDefault();
const id = document.getElementById('scraperEditId').value;
const data = {
city: document.getElementById('scraperCity').value.trim(),
url: document.getElementById('scraperUrl').value.trim(),
section: document.getElementById('scraperSection').value.trim(),
subsection: document.getElementById('scraperSubsection').value.trim(),
type: document.getElementById('scraperType').value,
prompt: document.getElementById('scraperPrompt').value.trim(),
model: document.getElementById('scraperModel').value,
enabled: document.getElementById('scraperEnabled').checked,
};
try {
const url = id ? `/api/scrapers/${id}` : '/api/scrapers';
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);
closeScraperModal();
await loadScrapers();
} catch (err) {
alert('保存失败: ' + err.message);
}
}
async function deleteScraper(id) {
const item = scrapersList.find(s => s.id === id);
if (!confirm(`确定要删除「${item?.city} - ${item?.type}」吗?`)) return;
try {
const res = await fetch(`/api/scrapers/${id}`, { method: 'DELETE' });
const json = await res.json();
if (!json.success) throw new Error(json.error);
await loadScrapers();
} catch (err) {
alert('删除失败: ' + err.message);
}
}
async function toggleScraper(id, enabled) {
try {
const res = await fetch(`/api/scrapers/${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 loadScrapers();
} catch (err) {
alert('操作失败: ' + err.message);
}
}
async function runScraper(id) {
const item = scrapersList.find(s => s.id === id);
const resultDiv = document.getElementById('scraperRunResult');
const contentDiv = document.getElementById('scraperRunResultContent');
resultDiv.style.display = 'block';
contentDiv.textContent = `正在测试抓取「${item?.city} - ${item?.type}」,请稍候...`;
try {
const res = await fetch(`/api/scrapers/${id}/run`, { method: 'POST' });
const json = await res.json();
if (!json.success) throw new Error(json.error);
contentDiv.textContent = JSON.stringify(json.data, null, 2);
} catch (err) {
contentDiv.textContent = '❌ 测试失败: ' + err.message;
}
}
// 切换到抓取来源 Tab 时自动加载
const _origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
document.addEventListener('DOMContentLoaded', () => {
// 拦截 tab 切换,在进入 scrapers tab 时加载数据
document.querySelectorAll('.tab').forEach(btn => {
if (btn.textContent.trim() === '抓取来源') {
btn.addEventListener('click', () => { loadScrapers(); });
}
});
// 点击弹窗遮罩关闭
document.getElementById('scraperModal').addEventListener('click', function (e) {
if (e.target === this) closeScraperModal();
});
});
// 批量运行所有已启用来源
async function runAllScrapers() {
const enabled = scrapersList.filter(s => s.enabled);
if (enabled.length === 0) {
alert('没有已启用的抓取来源,请先在列表中启用至少一个来源。');
return;
}
if (!confirm(`确定要运行全部 ${enabled.length} 个已启用的抓取来源吗?\n结果将自动保存,可在「抓取结果」页查看。`)) return;
const btn = document.getElementById('btnRunAll');
const statusDiv = document.getElementById('batchRunStatus');
btn.disabled = true;
btn.textContent = '运行中...';
statusDiv.style.display = 'block';
statusDiv.innerHTML = `⏳ 正在运行 ${enabled.length} 个抓取来源,请稍候...`;
try {
const res = await fetch('/api/scrape/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const json = await res.json();
if (!json.success) throw new Error(json.error);
const results = json.data || [];
const ok = results.filter(r => !r.error).length;
const err = results.filter(r => r.error).length;
statusDiv.innerHTML = `
✅ 批量抓取完成!成功 <strong>${ok}</strong> 条,失败 <strong>${err}</strong> 条。
&nbsp;&nbsp;<a href="/results.html" target="_blank" style="color:#667eea;font-weight:600;text-decoration:underline;">点击查看抓取结果 →</a>
`;
} catch (e) {
statusDiv.innerHTML = `❌ 批量运行失败: ${e.message}`;
} finally {
btn.disabled = false;
btn.innerHTML = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg> 运行全部启用`;
}
}
</script>
</body>
</html>