From 2a5fd99319e4522aaa373f178752b7296be9afd6 Mon Sep 17 00:00:00 2001 From: zhaojunlong <5482498@qq.com> Date: Thu, 19 Mar 2026 15:03:43 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(project):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在项目查询界面添加导出按钮,支持将项目结果导出为CSV文件 - 实现exportProjectResults函数处理导出逻辑,包括参数验证和文件下载 - 新增validateProjectFilters函数用于验证项目筛选条件 - 新增buildProjectQueryParams函数构建查询参数 - 在服务端添加/api/projects/export接口,返回CSV格式数据 - 实现CSV文件生成和下载功能,包含中文文件名处理 - 重构项目查询逻辑,提取getProjects函数复用代码 ``` --- public/index.html | 110 +++++++++++++++++++++++++++++++++++++++------ src/resultStore.js | 31 +++++++++++-- src/server.js | 91 +++++++++++++++++++++++++++++-------- 3 files changed, 196 insertions(+), 36 deletions(-) diff --git a/public/index.html b/public/index.html index 08cdc2e..3321474 100644 --- a/public/index.html +++ b/public/index.html @@ -622,6 +622,7 @@
+
@@ -1130,21 +1131,10 @@ 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); - }); + validateProjectFilters(projectFilters); + await loadProjectFilters(); + const params = buildProjectQueryParams({ projectFilters, includePagination: true }); const res = await fetch('/api/projects?' + params); const json = await res.json(); @@ -1198,6 +1188,31 @@ }; } + function validateProjectFilters(projectFilters) { + if (projectFilters.minAmount && projectFilters.maxAmount && Number(projectFilters.minAmount) > Number(projectFilters.maxAmount)) { + throw new Error('最小金额不能大于最大金额'); + } + + if (projectFilters.startDate && projectFilters.endDate && projectFilters.startDate > projectFilters.endDate) { + throw new Error('开始日期不能晚于结束日期'); + } + } + + function buildProjectQueryParams({ projectFilters = getProjectFilterValues(), includePagination = true } = {}) { + const params = new URLSearchParams(); + + if (includePagination) { + params.set('page', currentProjectsPage); + params.set('pageSize', 10); + } + + Object.entries(projectFilters).forEach(([key, value]) => { + if (value !== '') params.set(key, value); + }); + + return params; + } + function searchProjectResults() { currentProjectsPage = 1; loadProjectResults(); @@ -1220,6 +1235,73 @@ loadProjectResults(); } + async function exportProjectResults(buttonEl) { + const btn = buttonEl || document.getElementById('btnExportProjects'); + const originalText = btn ? btn.textContent : '导出'; + + try { + const projectFilters = getProjectFilterValues(); + validateProjectFilters(projectFilters); + + if (btn) { + btn.disabled = true; + btn.textContent = '导出中...'; + } + + const res = await fetch('/api/projects/export?' + buildProjectQueryParams({ + projectFilters, + includePagination: false, + })); + + if (!res.ok) { + let message = '导出失败'; + try { + const json = await res.json(); + message = json.error || message; + } catch (_error) { + // Ignore JSON parse failure and use the fallback message. + } + throw new Error(message); + } + + const blob = await res.blob(); + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = getDownloadFilename( + res.headers.get('content-disposition'), + `projects-${new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-')}.csv` + ); + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(downloadUrl); + } catch (e) { + alert('导出失败: ' + e.message); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = originalText; + } + } + } + + function getDownloadFilename(contentDisposition, fallbackName) { + if (!contentDisposition) return fallbackName; + + const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match && utf8Match[1]) { + return decodeURIComponent(utf8Match[1]); + } + + const asciiMatch = contentDisposition.match(/filename="([^"]+)"/i) || contentDisposition.match(/filename=([^;]+)/i); + if (asciiMatch && asciiMatch[1]) { + return asciiMatch[1].trim(); + } + + return fallbackName; + } + function renderResults({ data, total, page, pageSize, listId, paginationId }) { const container = document.getElementById(listId); if (!data || data.length === 0) { diff --git a/src/resultStore.js b/src/resultStore.js index 0e7c63c..628587c 100644 --- a/src/resultStore.js +++ b/src/resultStore.js @@ -690,12 +690,11 @@ export function listProjects({ } = {}) { initResultsStore(); - const rows = queryBaseRows({}); - const projects = buildProjectList(rows, { + const projects = getProjects({ dedupeByName, city, section, - projectNameKeyword: projectName, + projectName, minAmount, maxAmount, startDate, @@ -713,10 +712,34 @@ export function listProjects({ }; } +export function getProjects({ + city, + section, + projectName, + minAmount, + maxAmount, + startDate, + endDate, + dedupeByName = true, +} = {}) { + initResultsStore(); + + return buildProjectList(queryBaseRows({}), { + dedupeByName, + city, + section, + projectNameKeyword: projectName, + minAmount, + maxAmount, + startDate, + endDate, + }); +} + export function getProjectFilters({ dedupeByName = true } = {}) { initResultsStore(); - const projects = buildProjectList(queryBaseRows({}), { dedupeByName }); + const projects = getProjects({ dedupeByName }); const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))]; const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))]; diff --git a/src/server.js b/src/server.js index 3c9b9f2..a082280 100644 --- a/src/server.js +++ b/src/server.js @@ -12,6 +12,7 @@ import { deleteTaskById, listResults, listProjects, + getProjects, deleteResultById, clearResults, getResultFilters, @@ -90,6 +91,58 @@ function mergeConfigWithExistingSecrets(incoming = {}) { return next; } +function getProjectQueryFilters(query = {}) { + return { + city: query.city, + section: query.section, + projectName: query.projectName, + minAmount: query.minAmount, + maxAmount: query.maxAmount, + startDate: query.startDate, + endDate: query.endDate, + }; +} + +function formatExportTimestamp(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + '-', + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(''); +} + +function escapeCsvCell(value) { + if (value === null || value === undefined) return ''; + const stringValue = String(value); + if (/[",\r\n]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; +} + +function buildProjectsCsv(projects = []) { + const columns = [ + ['城市', (project) => project.city || ''], + ['板块', (project) => project.section || project.type || ''], + ['项目名称', (project) => project.projectName || ''], + ['金额(元)', (project) => Number.isFinite(project.amountYuan) ? project.amountYuan : ''], + ['发布日期', (project) => project.date || ''], + ['详情链接', (project) => project.detailLink || ''], + ]; + + const lines = [ + columns.map(([header]) => escapeCsvCell(header)).join(','), + ...projects.map((project) => columns.map(([, getter]) => escapeCsvCell(getter(project))).join(',')), + ]; + + return `\uFEFF${lines.join('\r\n')}`; +} + let isRunning = false; let runningStatus = null; @@ -227,25 +280,10 @@ app.get('/api/results/filters', (_req, res) => { app.get('/api/projects', (req, res) => { try { - const { - city, - section, - projectName, - minAmount, - maxAmount, - startDate, - endDate, - page = 1, - pageSize = 20, - } = req.query; + const { page = 1, pageSize = 20 } = req.query; + const filters = getProjectQueryFilters(req.query); const result = listProjects({ - city, - section, - projectName, - minAmount, - maxAmount, - startDate, - endDate, + ...filters, page, pageSize, dedupeByName: true, @@ -256,6 +294,23 @@ app.get('/api/projects', (req, res) => { } }); +app.get('/api/projects/export', (req, res) => { + try { + const projects = getProjects({ + ...getProjectQueryFilters(req.query), + dedupeByName: true, + }); + const csv = buildProjectsCsv(projects); + const filename = `projects-${formatExportTimestamp()}.csv`; + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`); + res.send(csv); + } 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 }) });