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 @@
+
@@ -573,13 +574,84 @@ - +
+ +
+
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ 按项目名称拆分并去重后展示 +
+ + + +
+
+
+ +
+
加载中...
+
+ + + + + + + + + + + + + + + + +
城市板块项目名称金额发布日期详情链接
加载中...
+
+
+
+
+
@@ -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 }); }