feat(project): 添加项目数据导出功能

- 在项目查询界面添加导出按钮,支持将项目结果导出为CSV文件
- 实现exportProjectResults函数处理导出逻辑,包括参数验证和文件下载
- 新增validateProjectFilters函数用于验证项目筛选条件
- 新增buildProjectQueryParams函数构建查询参数
- 在服务端添加/api/projects/export接口,返回CSV格式数据
- 实现CSV文件生成和下载功能,包含中文文件名处理
- 重构项目查询逻辑,提取getProjects函数复用代码
```
This commit is contained in:
2026-03-19 15:03:43 +08:00
parent 7bfba04199
commit 2a5fd99319
3 changed files with 196 additions and 36 deletions

View File

@@ -622,6 +622,7 @@
<div style="display:flex;gap:10px;align-items:center;"> <div style="display:flex;gap:10px;align-items:center;">
<button class="btn btn-primary btn-sm" onclick="searchProjectResults()">查询</button> <button class="btn btn-primary btn-sm" onclick="searchProjectResults()">查询</button>
<button class="btn btn-secondary btn-sm" onclick="resetProjectFilters()">重置</button> <button class="btn btn-secondary btn-sm" onclick="resetProjectFilters()">重置</button>
<button class="btn btn-success btn-sm" id="btnExportProjects" onclick="exportProjectResults(this)">导出</button>
<!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> --> <!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> -->
</div> </div>
</div> </div>
@@ -1130,21 +1131,10 @@
async function loadProjectResults() { async function loadProjectResults() {
try { try {
await loadProjectFilters();
const params = new URLSearchParams({ page: currentProjectsPage, pageSize: 10 });
const projectFilters = getProjectFilterValues(); const projectFilters = getProjectFilterValues();
validateProjectFilters(projectFilters);
if (projectFilters.minAmount && projectFilters.maxAmount && Number(projectFilters.minAmount) > Number(projectFilters.maxAmount)) { await loadProjectFilters();
throw new Error('最小金额不能大于最大金额'); const params = buildProjectQueryParams({ projectFilters, includePagination: true });
}
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 res = await fetch('/api/projects?' + params);
const json = await res.json(); 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() { function searchProjectResults() {
currentProjectsPage = 1; currentProjectsPage = 1;
loadProjectResults(); loadProjectResults();
@@ -1220,6 +1235,73 @@
loadProjectResults(); 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 }) { function renderResults({ data, total, page, pageSize, listId, paginationId }) {
const container = document.getElementById(listId); const container = document.getElementById(listId);
if (!data || data.length === 0) { if (!data || data.length === 0) {

View File

@@ -690,12 +690,11 @@ export function listProjects({
} = {}) { } = {}) {
initResultsStore(); initResultsStore();
const rows = queryBaseRows({}); const projects = getProjects({
const projects = buildProjectList(rows, {
dedupeByName, dedupeByName,
city, city,
section, section,
projectNameKeyword: projectName, projectName,
minAmount, minAmount,
maxAmount, maxAmount,
startDate, 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 } = {}) { export function getProjectFilters({ dedupeByName = true } = {}) {
initResultsStore(); initResultsStore();
const projects = buildProjectList(queryBaseRows({}), { dedupeByName }); const projects = getProjects({ dedupeByName });
const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))]; const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))];
const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))]; const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))];

View File

@@ -12,6 +12,7 @@ import {
deleteTaskById, deleteTaskById,
listResults, listResults,
listProjects, listProjects,
getProjects,
deleteResultById, deleteResultById,
clearResults, clearResults,
getResultFilters, getResultFilters,
@@ -90,6 +91,58 @@ function mergeConfigWithExistingSecrets(incoming = {}) {
return next; 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 isRunning = false;
let runningStatus = null; let runningStatus = null;
@@ -227,25 +280,10 @@ app.get('/api/results/filters', (_req, res) => {
app.get('/api/projects', (req, res) => { app.get('/api/projects', (req, res) => {
try { try {
const { const { page = 1, pageSize = 20 } = req.query;
city, const filters = getProjectQueryFilters(req.query);
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page = 1,
pageSize = 20,
} = req.query;
const result = listProjects({ const result = listProjects({
city, ...filters,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page, page,
pageSize, pageSize,
dedupeByName: true, 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) => { app.get('/api/projects/filters', (_req, res) => {
try { try {
res.json({ success: true, data: getProjectFilters({ dedupeByName: true }) }); res.json({ success: true, data: getProjectFilters({ dedupeByName: true }) });