diff --git a/public/index.html b/public/index.html
index 0f1ab2a..08cdc2e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -520,6 +520,7 @@
@@ -712,16 +784,18 @@
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', 'settings'][i] === name);
+ 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();
}
@@ -1030,36 +1104,127 @@
// ===== 结果展示 =====
async function loadResults() {
- const city = document.getElementById('filterCity').value;
try {
- // 加载筛选选项
- const filtersRes = await fetch('/api/results/filters');
- const filtersJson = await filtersRes.json();
- if (filtersJson.success) {
- const sel = document.getElementById('filterCity');
- const curVal = sel.value;
- sel.innerHTML = '
' +
- (filtersJson.data.cities || []).map(c => `
`).join('');
- }
-
- // 加载结果
+ 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(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) {
document.getElementById('resultsList').innerHTML = `
加载失败: ${e.message}
`;
+ document.getElementById('resultsPagination').innerHTML = '';
}
}
- function renderResults(data, total, page, pageSize) {
- const container = document.getElementById('resultsList');
+ async function loadProjectResults() {
+ 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 = `
| 加载失败: ${e.message} |
`;
+ 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 = `
` +
+ items.map(item => `
`).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) {
container.innerHTML = '
暂无结果
';
- document.getElementById('resultsPagination').innerHTML = '';
+ document.getElementById(paginationId).innerHTML = '';
return;
}
@@ -1070,7 +1235,7 @@
const itemsHtml = results.length > 0 ? results.map(item => `
${item.type || '-'}
-
${item.project_name || '-'}
+
${item.project_name || item.projectName || item.title || '-'}
${item.amount_yuan ? item.amount_yuan.toLocaleString() + ' 元' : '-'}
${item.date || '-'}
${(item.detail_link || item.target_link) ? `
查看` : ''}
@@ -1092,26 +1257,69 @@
`;
}).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 = '
| 暂无去重后的项目 |
';
+ 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 => `
+
+ | ${escHtml(project.city || '-')} |
+ ${escHtml(project.section || project.type || '-')} |
+ ${escHtml(project.projectName || '-')} |
+ ${formatAmount(project.amountYuan)} |
+ ${escHtml(project.date || '-')} |
+ ${project.detailLink ? `查看详情` : '-'} |
+
+ `).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 += `
`;
+ pHtml += `
`;
}
- document.getElementById('resultsPagination').innerHTML = `
第 ${page}/${totalPages} 页,共 ${total} 条
${pHtml}`;
+ pagination.innerHTML = `
第 ${page}/${totalPages} 页,共 ${total} 条
${pHtml}`;
} 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) {
if (!confirm('确定删除这条结果?')) return;
try {
await fetch(`/api/results/${id}`, { method: 'DELETE' });
- loadResults();
+ await Promise.all([loadResults(), loadProjectResults()]);
} catch (e) {
alert('删除失败: ' + e.message);
}
@@ -1121,12 +1329,28 @@
if (!confirm('确定要清空全部结果吗?此操作不可恢复。')) return;
try {
await fetch('/api/results', { method: 'DELETE' });
- loadResults();
+ 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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
// ===== 设置 =====
async function loadSettings() {
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();
loadSettings();
diff --git a/public/results.html b/public/results.html
index a008f0d..29e2bd8 100644
--- a/public/results.html
+++ b/public/results.html
@@ -1166,7 +1166,7 @@
-
+
diff --git a/src/resultStore.js b/src/resultStore.js
index 305d437..0e7c63c 100644
--- a/src/resultStore.js
+++ b/src/resultStore.js
@@ -287,6 +287,215 @@ function matchType(record, 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() {
if (initialized) return;
ensureSchema();
@@ -409,12 +618,13 @@ export function appendResult(result) {
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();
let results = queryBaseRows({ city, taskId });
if (section) results = results.filter((record) => matchSection(record, section));
if (type) results = results.filter((record) => matchType(record, type));
+ if (projectMode) results = dedupeRowsByProjectName(results);
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
@@ -439,10 +649,10 @@ export function clearResults() {
getDb().prepare('DELETE FROM results').run();
}
-export function getResultFilters() {
+export function getResultFilters({ projectMode = false } = {}) {
initResultsStore();
- const rows = queryBaseRows({});
+ const rows = projectMode ? dedupeRowsByProjectName(queryBaseRows({})) : queryBaseRows({});
const cities = [...new Set(rows.map((row) => row.city).filter(Boolean))];
const sections = 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() {
return DB_PATH;
}
diff --git a/src/server.js b/src/server.js
index 533c986..3c9b9f2 100644
--- a/src/server.js
+++ b/src/server.js
@@ -11,9 +11,11 @@ import {
updateTask,
deleteTaskById,
listResults,
+ listProjects,
deleteResultById,
clearResults,
getResultFilters,
+ getProjectFilters,
appendResult,
} from './resultStore.js';
import { initScheduler, runTaskNow, reloadScheduler, getSchedulerStatus } from './scheduler.js';
@@ -184,8 +186,9 @@ function runTasksInBackground(tasks) {
app.get('/api/results', (req, res) => {
try {
- const { city, section, type, page = 1, pageSize = 20, taskId } = req.query;
- const result = listResults({ city, section, type, page, pageSize, taskId });
+ const { city, section, type, page = 1, pageSize = 20, taskId, view } = req.query;
+ const projectMode = view === 'projects';
+ const result = listResults({ city, section, type, page, pageSize, taskId, projectMode });
res.json({ success: true, ...result });
} catch (error) {
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) => {
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) {
res.status(500).json({ success: false, error: error.message });
}