```
feat: 添加项目管理功能 添加了项目管理标签页,支持按城市、板块、项目名称、金额范围、日期等条件进行过滤查询, 包含完整的前端界面和后端API接口,实现数据去重和分页功能 ```
This commit is contained in:
@@ -520,6 +520,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" onclick="switchTab('tasks')">任务配置</button>
|
<button class="tab active" onclick="switchTab('tasks')">任务配置</button>
|
||||||
<button class="tab" onclick="switchTab('results')">抓取结果</button>
|
<button class="tab" onclick="switchTab('results')">抓取结果</button>
|
||||||
|
<button class="tab" onclick="switchTab('projects')">项目管理</button>
|
||||||
<button class="tab" onclick="switchTab('settings')">系统设置</button>
|
<button class="tab" onclick="switchTab('settings')">系统设置</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -573,13 +574,84 @@
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="loadResults()">刷新</button>
|
<button class="btn btn-secondary btn-sm" onclick="loadResults()">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button>
|
<!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="resultsList"></div>
|
<div id="resultsList"></div>
|
||||||
<div id="resultsPagination" style="text-align:center;margin-top:16px;"></div>
|
<div id="resultsPagination" style="text-align:center;margin-top:16px;"></div>
|
||||||
</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-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 ===== -->
|
<!-- ===== 系统设置 Tab ===== -->
|
||||||
<div id="tab-settings" class="tab-content">
|
<div id="tab-settings" class="tab-content">
|
||||||
<!-- Agent 配置 -->
|
<!-- Agent 配置 -->
|
||||||
@@ -712,16 +784,18 @@
|
|||||||
let currentTasksPage = 1;
|
let currentTasksPage = 1;
|
||||||
const tasksPageSize = 10;
|
const tasksPageSize = 10;
|
||||||
let currentResultsPage = 1;
|
let currentResultsPage = 1;
|
||||||
|
let currentProjectsPage = 1;
|
||||||
|
|
||||||
// ===== Tab 切换 =====
|
// ===== Tab 切换 =====
|
||||||
function switchTab(name) {
|
function switchTab(name) {
|
||||||
document.querySelectorAll('.tab').forEach((t, i) => {
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
||||||
t.classList.toggle('active', ['tasks', 'results', 'settings'][i] === name);
|
t.classList.toggle('active', ['tasks', 'results', 'projects', 'settings'][i] === name);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
document.getElementById('tab-' + name).classList.add('active');
|
document.getElementById('tab-' + name).classList.add('active');
|
||||||
|
|
||||||
if (name === 'results') loadResults();
|
if (name === 'results') loadResults();
|
||||||
|
if (name === 'projects') loadProjectResults();
|
||||||
if (name === 'settings') loadSettings();
|
if (name === 'settings') loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,36 +1104,127 @@
|
|||||||
|
|
||||||
// ===== 结果展示 =====
|
// ===== 结果展示 =====
|
||||||
async function loadResults() {
|
async function loadResults() {
|
||||||
const city = document.getElementById('filterCity').value;
|
|
||||||
try {
|
try {
|
||||||
// 加载筛选选项
|
await loadResultCityOptions('filterCity');
|
||||||
const filtersRes = await fetch('/api/results/filters');
|
const city = document.getElementById('filterCity').value;
|
||||||
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 });
|
const params = new URLSearchParams({ page: currentResultsPage, pageSize: 10 });
|
||||||
if (city) params.set('city', city);
|
if (city) params.set('city', city);
|
||||||
|
|
||||||
const res = await fetch('/api/results?' + params);
|
const res = await fetch('/api/results?' + params);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!json.success) throw new Error(json.error);
|
if (!json.success) throw new Error(json.error);
|
||||||
|
|
||||||
renderResults(json.data, json.total, json.page, json.pageSize);
|
renderResults({
|
||||||
|
data: json.data,
|
||||||
|
total: json.total,
|
||||||
|
page: json.page,
|
||||||
|
pageSize: json.pageSize,
|
||||||
|
listId: 'resultsList',
|
||||||
|
paginationId: 'resultsPagination',
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('resultsList').innerHTML = `<div class="status-box status-error">加载失败: ${e.message}</div>`;
|
document.getElementById('resultsList').innerHTML = `<div class="status-box status-error">加载失败: ${e.message}</div>`;
|
||||||
|
document.getElementById('resultsPagination').innerHTML = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderResults(data, total, page, pageSize) {
|
async function loadProjectResults() {
|
||||||
const container = document.getElementById('resultsList');
|
try {
|
||||||
|
await loadProjectFilters();
|
||||||
|
const params = new URLSearchParams({ page: currentProjectsPage, pageSize: 10 });
|
||||||
|
const projectFilters = getProjectFilterValues();
|
||||||
|
|
||||||
|
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('开始日期不能晚于结束日期');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(projectFilters).forEach(([key, value]) => {
|
||||||
|
if (value !== '') params.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 searchProjectResults() {
|
||||||
|
currentProjectsPage = 1;
|
||||||
|
loadProjectResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProjectFilters() {
|
||||||
|
[
|
||||||
|
'projectFilterCity',
|
||||||
|
'projectFilterSection',
|
||||||
|
'projectFilterProjectName',
|
||||||
|
'projectFilterMinAmount',
|
||||||
|
'projectFilterMaxAmount',
|
||||||
|
'projectFilterStartDate',
|
||||||
|
'projectFilterEndDate',
|
||||||
|
].forEach((id) => {
|
||||||
|
document.getElementById(id).value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
currentProjectsPage = 1;
|
||||||
|
loadProjectResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults({ data, total, page, pageSize, listId, paginationId }) {
|
||||||
|
const container = document.getElementById(listId);
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
container.innerHTML = '<div class="empty-state">暂无结果</div>';
|
container.innerHTML = '<div class="empty-state">暂无结果</div>';
|
||||||
document.getElementById('resultsPagination').innerHTML = '';
|
document.getElementById(paginationId).innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,7 +1235,7 @@
|
|||||||
const itemsHtml = results.length > 0 ? results.map(item => `
|
const itemsHtml = results.length > 0 ? results.map(item => `
|
||||||
<div class="result-item">
|
<div class="result-item">
|
||||||
<span class="type">${item.type || '-'}</span>
|
<span class="type">${item.type || '-'}</span>
|
||||||
<span class="name">${item.project_name || '-'}</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="amount">${item.amount_yuan ? item.amount_yuan.toLocaleString() + ' 元' : '-'}</span>
|
||||||
<span class="date">${item.date || '-'}</span>
|
<span class="date">${item.date || '-'}</span>
|
||||||
${(item.detail_link || item.target_link) ? `<a href="${item.detail_link || item.target_link}" target="_blank">查看</a>` : ''}
|
${(item.detail_link || item.target_link) ? `<a href="${item.detail_link || item.target_link}" target="_blank">查看</a>` : ''}
|
||||||
@@ -1092,26 +1257,69 @@
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).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.section || project.type || '-')}</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 totalPages = Math.ceil(total / pageSize);
|
||||||
|
const pagination = document.getElementById(paginationId);
|
||||||
|
|
||||||
if (totalPages > 1) {
|
if (totalPages > 1) {
|
||||||
let pHtml = '';
|
let pHtml = '';
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
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>`;
|
pHtml += `<button class="btn btn-sm ${i === page ? 'btn-primary' : 'btn-secondary'}" onclick="goPage('${view}', ${i})" style="margin:0 3px;">${i}</button>`;
|
||||||
}
|
}
|
||||||
document.getElementById('resultsPagination').innerHTML = `<div>第 ${page}/${totalPages} 页,共 ${total} 条</div>${pHtml}`;
|
pagination.innerHTML = `<div>第 ${page}/${totalPages} 页,共 ${total} 条</div>${pHtml}`;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('resultsPagination').innerHTML = total > 0 ? `共 ${total} 条` : '';
|
pagination.innerHTML = total > 0 ? `共 ${total} 条` : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(p) { currentResultsPage = p; loadResults(); }
|
function goPage(view, p) {
|
||||||
|
if (view === 'projects') {
|
||||||
|
currentProjectsPage = p;
|
||||||
|
loadProjectResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentResultsPage = p;
|
||||||
|
loadResults();
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteResult(id) {
|
async function deleteResult(id) {
|
||||||
if (!confirm('确定删除这条结果?')) return;
|
if (!confirm('确定删除这条结果?')) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/results/${id}`, { method: 'DELETE' });
|
await fetch(`/api/results/${id}`, { method: 'DELETE' });
|
||||||
loadResults();
|
await Promise.all([loadResults(), loadProjectResults()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('删除失败: ' + e.message);
|
alert('删除失败: ' + e.message);
|
||||||
}
|
}
|
||||||
@@ -1121,12 +1329,28 @@
|
|||||||
if (!confirm('确定要清空全部结果吗?此操作不可恢复。')) return;
|
if (!confirm('确定要清空全部结果吗?此操作不可恢复。')) return;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/results', { method: 'DELETE' });
|
await fetch('/api/results', { method: 'DELETE' });
|
||||||
loadResults();
|
currentResultsPage = 1;
|
||||||
|
currentProjectsPage = 1;
|
||||||
|
await Promise.all([loadResults(), loadProjectResults()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('清空失败: ' + e.message);
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 设置 =====
|
// ===== 设置 =====
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
@@ -1215,7 +1439,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
loadTasks();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1166,7 +1166,7 @@
|
|||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button class="btn btn-ghost" onclick="resetFilters()">↺ 重置</button>
|
<button class="btn btn-ghost" onclick="resetFilters()">↺ 重置</button>
|
||||||
<button class="btn btn-primary" onclick="loadResults()"> 刷新</button>
|
<button class="btn btn-primary" onclick="loadResults()"> 刷新</button>
|
||||||
<button class="btn btn-danger" onclick="confirmClearAll()"> 清空全部</button>
|
<!-- <button class="btn btn-danger" onclick="confirmClearAll()"> 清空全部</button> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,215 @@ function matchType(record, type) {
|
|||||||
return items.some((item) => item.type === type);
|
return items.some((item) => item.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProjectName(item) {
|
||||||
|
if (!item || typeof item !== 'object') return '';
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
item.project_name,
|
||||||
|
item.projectName,
|
||||||
|
item.title,
|
||||||
|
item.name,
|
||||||
|
item.bidName,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
return candidate.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeRowsByProjectName(rows) {
|
||||||
|
const seenProjectNames = new Set();
|
||||||
|
const dedupedRows = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const items = Array.isArray(row.data?.results) ? row.data.results : [];
|
||||||
|
if (items.length === 0) continue;
|
||||||
|
|
||||||
|
const uniqueItems = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const projectName = normalizeProjectName(item);
|
||||||
|
|
||||||
|
if (!projectName) {
|
||||||
|
uniqueItems.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenProjectNames.has(projectName)) continue;
|
||||||
|
|
||||||
|
seenProjectNames.add(projectName);
|
||||||
|
uniqueItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueItems.length === 0) continue;
|
||||||
|
|
||||||
|
dedupedRows.push({
|
||||||
|
...row,
|
||||||
|
data: {
|
||||||
|
...(row.data || {}),
|
||||||
|
results: uniqueItems,
|
||||||
|
total: uniqueItems.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickProjectLink(item) {
|
||||||
|
const candidates = [item?.detail_link, item?.target_link, item?.url, item?.href];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProjectSection(item) {
|
||||||
|
if (typeof item?.section === 'string' && item.section.trim()) return item.section.trim();
|
||||||
|
if (typeof item?.type === 'string' && item.type.trim()) {
|
||||||
|
return item.type.split(/[-/]/)[0].trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProjectAmount(item) {
|
||||||
|
if (typeof item?.amount_yuan === 'number' && Number.isFinite(item.amount_yuan)) {
|
||||||
|
return item.amount_yuan;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item?.amount === 'number' && Number.isFinite(item.amount)) {
|
||||||
|
return item.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectSortValue(project) {
|
||||||
|
const dateValue = Date.parse(project.date || '');
|
||||||
|
const scrapedAtValue = Date.parse(project.scrapedAt || '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateValue: Number.isFinite(dateValue) ? dateValue : 0,
|
||||||
|
scrapedAtValue: Number.isFinite(scrapedAtValue) ? scrapedAtValue : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareProjectsDesc(a, b) {
|
||||||
|
const aValue = projectSortValue(a);
|
||||||
|
const bValue = projectSortValue(b);
|
||||||
|
|
||||||
|
if (bValue.dateValue !== aValue.dateValue) return bValue.dateValue - aValue.dateValue;
|
||||||
|
if (bValue.scrapedAtValue !== aValue.scrapedAtValue) return bValue.scrapedAtValue - aValue.scrapedAtValue;
|
||||||
|
return (a.projectName || '').localeCompare(b.projectName || '', 'zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(value) {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesSearchText(source, keyword) {
|
||||||
|
if (!keyword) return true;
|
||||||
|
return normalizeSearchText(source).includes(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumberFilter(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateFilter(value) {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) return null;
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchProjectFilters(project, filters = {}) {
|
||||||
|
const cityKeyword = normalizeSearchText(filters.city);
|
||||||
|
const sectionKeyword = normalizeSearchText(filters.section);
|
||||||
|
const projectNameKeyword = normalizeSearchText(filters.projectName);
|
||||||
|
const minAmount = parseNumberFilter(filters.minAmount);
|
||||||
|
const maxAmount = parseNumberFilter(filters.maxAmount);
|
||||||
|
const startDate = parseDateFilter(filters.startDate);
|
||||||
|
const endDate = parseDateFilter(filters.endDate);
|
||||||
|
const projectDate = parseDateFilter(project.date);
|
||||||
|
|
||||||
|
if (!includesSearchText(project.city, cityKeyword)) return false;
|
||||||
|
if (!includesSearchText(project.section || project.type, sectionKeyword)) return false;
|
||||||
|
if (!includesSearchText(project.projectName, projectNameKeyword)) return false;
|
||||||
|
if (minAmount !== null && (project.amountYuan === null || project.amountYuan < minAmount)) return false;
|
||||||
|
if (maxAmount !== null && (project.amountYuan === null || project.amountYuan > maxAmount)) return false;
|
||||||
|
if (startDate !== null && (projectDate === null || projectDate < startDate)) return false;
|
||||||
|
if (endDate !== null && (projectDate === null || projectDate > endDate)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectList(rows, {
|
||||||
|
dedupeByName = false,
|
||||||
|
city,
|
||||||
|
section,
|
||||||
|
projectNameKeyword,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
} = {}) {
|
||||||
|
const seenProjectNames = new Set();
|
||||||
|
const projects = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const items = Array.isArray(row.data?.results) ? row.data.results : [];
|
||||||
|
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
const projectName = normalizeProjectName(item);
|
||||||
|
if (!projectName) continue;
|
||||||
|
|
||||||
|
const projectSection = parseProjectSection(item);
|
||||||
|
if (city && row.city !== city) continue;
|
||||||
|
if (section && projectSection !== section) continue;
|
||||||
|
|
||||||
|
if (dedupeByName) {
|
||||||
|
if (seenProjectNames.has(projectName)) continue;
|
||||||
|
seenProjectNames.add(projectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.push({
|
||||||
|
id: `${row.id}:${index}`,
|
||||||
|
resultId: row.id,
|
||||||
|
taskId: row.taskId,
|
||||||
|
city: row.city || '',
|
||||||
|
section: projectSection,
|
||||||
|
type: typeof item?.type === 'string' ? item.type : '',
|
||||||
|
projectName,
|
||||||
|
amountYuan: parseProjectAmount(item),
|
||||||
|
date: typeof item?.date === 'string' ? item.date : '',
|
||||||
|
detailLink: pickProjectLink(item),
|
||||||
|
scrapedAt: row.scrapedAt,
|
||||||
|
raw: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects
|
||||||
|
.filter((project) => matchProjectFilters(project, {
|
||||||
|
city,
|
||||||
|
section,
|
||||||
|
projectName: projectNameKeyword,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}))
|
||||||
|
.sort(compareProjectsDesc);
|
||||||
|
}
|
||||||
|
|
||||||
export function initResultsStore() {
|
export function initResultsStore() {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
ensureSchema();
|
ensureSchema();
|
||||||
@@ -409,12 +618,13 @@ export function appendResult(result) {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listResults({ city, section, type, taskId, page = 1, pageSize = 20 } = {}) {
|
export function listResults({ city, section, type, taskId, page = 1, pageSize = 20, projectMode = false } = {}) {
|
||||||
initResultsStore();
|
initResultsStore();
|
||||||
|
|
||||||
let results = queryBaseRows({ city, taskId });
|
let results = queryBaseRows({ city, taskId });
|
||||||
if (section) results = results.filter((record) => matchSection(record, section));
|
if (section) results = results.filter((record) => matchSection(record, section));
|
||||||
if (type) results = results.filter((record) => matchType(record, type));
|
if (type) results = results.filter((record) => matchType(record, type));
|
||||||
|
if (projectMode) results = dedupeRowsByProjectName(results);
|
||||||
|
|
||||||
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
|
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
|
||||||
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
|
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
|
||||||
@@ -439,10 +649,10 @@ export function clearResults() {
|
|||||||
getDb().prepare('DELETE FROM results').run();
|
getDb().prepare('DELETE FROM results').run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResultFilters() {
|
export function getResultFilters({ projectMode = false } = {}) {
|
||||||
initResultsStore();
|
initResultsStore();
|
||||||
|
|
||||||
const rows = queryBaseRows({});
|
const rows = projectMode ? dedupeRowsByProjectName(queryBaseRows({})) : queryBaseRows({});
|
||||||
const cities = [...new Set(rows.map((row) => row.city).filter(Boolean))];
|
const cities = [...new Set(rows.map((row) => row.city).filter(Boolean))];
|
||||||
const sections = new Set();
|
const sections = new Set();
|
||||||
const types = new Set();
|
const types = new Set();
|
||||||
@@ -466,6 +676,56 @@ export function getResultFilters() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listProjects({
|
||||||
|
city,
|
||||||
|
section,
|
||||||
|
projectName,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
dedupeByName = true,
|
||||||
|
} = {}) {
|
||||||
|
initResultsStore();
|
||||||
|
|
||||||
|
const rows = queryBaseRows({});
|
||||||
|
const projects = buildProjectList(rows, {
|
||||||
|
dedupeByName,
|
||||||
|
city,
|
||||||
|
section,
|
||||||
|
projectNameKeyword: projectName,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
|
||||||
|
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
|
||||||
|
const start = (normalizedPage - 1) * normalizedPageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: projects.length,
|
||||||
|
page: normalizedPage,
|
||||||
|
pageSize: normalizedPageSize,
|
||||||
|
data: projects.slice(start, start + normalizedPageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectFilters({ dedupeByName = true } = {}) {
|
||||||
|
initResultsStore();
|
||||||
|
|
||||||
|
const projects = buildProjectList(queryBaseRows({}), { dedupeByName });
|
||||||
|
const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))];
|
||||||
|
const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))];
|
||||||
|
|
||||||
|
return {
|
||||||
|
cities,
|
||||||
|
sections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getResultsDbPath() {
|
export function getResultsDbPath() {
|
||||||
return DB_PATH;
|
return DB_PATH;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
updateTask,
|
updateTask,
|
||||||
deleteTaskById,
|
deleteTaskById,
|
||||||
listResults,
|
listResults,
|
||||||
|
listProjects,
|
||||||
deleteResultById,
|
deleteResultById,
|
||||||
clearResults,
|
clearResults,
|
||||||
getResultFilters,
|
getResultFilters,
|
||||||
|
getProjectFilters,
|
||||||
appendResult,
|
appendResult,
|
||||||
} from './resultStore.js';
|
} from './resultStore.js';
|
||||||
import { initScheduler, runTaskNow, reloadScheduler, getSchedulerStatus } from './scheduler.js';
|
import { initScheduler, runTaskNow, reloadScheduler, getSchedulerStatus } from './scheduler.js';
|
||||||
@@ -184,8 +186,9 @@ function runTasksInBackground(tasks) {
|
|||||||
|
|
||||||
app.get('/api/results', (req, res) => {
|
app.get('/api/results', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { city, section, type, page = 1, pageSize = 20, taskId } = req.query;
|
const { city, section, type, page = 1, pageSize = 20, taskId, view } = req.query;
|
||||||
const result = listResults({ city, section, type, page, pageSize, taskId });
|
const projectMode = view === 'projects';
|
||||||
|
const result = listResults({ city, section, type, page, pageSize, taskId, projectMode });
|
||||||
res.json({ success: true, ...result });
|
res.json({ success: true, ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
@@ -215,7 +218,47 @@ app.delete('/api/results', (_req, res) => {
|
|||||||
|
|
||||||
app.get('/api/results/filters', (_req, res) => {
|
app.get('/api/results/filters', (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({ success: true, data: getResultFilters() });
|
const projectMode = _req.query.view === 'projects';
|
||||||
|
res.json({ success: true, data: getResultFilters({ projectMode }) });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects', (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
city,
|
||||||
|
section,
|
||||||
|
projectName,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
} = req.query;
|
||||||
|
const result = listProjects({
|
||||||
|
city,
|
||||||
|
section,
|
||||||
|
projectName,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
dedupeByName: true,
|
||||||
|
});
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/filters', (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: getProjectFilters({ dedupeByName: true }) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user