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 }) });